🌐 feat(i18n): язык интерфейса — русский и английский

Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.

Цепочка одна на обе стороны: профиль сотрудника → организация → установка
→ браузер. Пустое значение на каждом уровне значит «как выше», а не
«русский»: тот, кто язык не трогал, поедет за организацией, когда владелец
её переключит, а выбравший явно — останется на своём.

Владелец задаёт язык организации («Настройки» → «Организация», рядом с
часовым поясом) и язык установки («Платформа») — на нём открываются вход,
сброс пароля и мастер первого запуска. Сотрудник переопределяет его в
профиле.

Русский каталог задаёт набор ключей, английский обязан его повторить:
во фронтенде это ловит tsc — пропущенный перевод становится ошибкой
сборки, — на бэкенде тест каталога, который заодно сверяет имена
параметров в фразах. Формы множественного числа берутся из CLDR через
Intl.PluralRules, даты и размеры — через Intl, а не через свои списки
месяцев.

Отдельно пришлось разобраться с тем, что уже записано в базу. Системные
события диалога писались готовой русской фразой, и перевести историю
задним числом нельзя — теперь пишется код события, а фразу собирает
сервер на языке читателя. То же с уведомлениями: они адресованы
операторам, а не одному человеку, и в смешанной команде готовая фраза
неверна для половины. Тон системной строки в треде и признак анонимного
посетителя больше не угадываются регуляркой по русским словам: под
английским бэкендом это просто перестало бы работать.

Письма уходят на языке получателя, а не отправителя: приглашение
отправляет владелец, а читает другой человек.

gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа
ради того же результата, что дают обычные словари, одинаковые на обеих
сторонах. Язык запроса при этом активируется штатным механизмом Django,
поэтому сообщения DRF переводятся тоже.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-10 00:36:52 +03:00
1 parent f91b3da27a
commit 6c3dec7339
282 files changed
+8185 -2561

No files matched your search

+2 -1
View File
@@ -22,6 +22,7 @@ from chatballs.channels.services import (
update_channel,
)
from chatballs.conversations.models import LifecycleState
from chatballs.i18n import t
from chatballs.tenancy.context import TenantContext
@@ -170,7 +171,7 @@ def _unique_agent_code(organization_id: int, name: str) -> str:
candidate = f"{base}-{suffix}"
if candidate not in taken:
return candidate
raise ValidationError({"name": "Не удалось подобрать уникальный код агента"})
raise ValidationError({"name": t("ai.agent_code_collision")})
def ensure_channel_agent(channel: Channel) -> AIAgent:
+14 -9
View File
@@ -24,9 +24,14 @@ from chatballs.channels import services as channel_services
from chatballs.channels.models import Channel
from chatballs.channels.runtime import run_channel_turn
from chatballs.channels.selectors import channel_for_context
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
AGENT_NOT_FOUND = {"detail": "Агент не найден"}
# Функция, а не константа: язык у каждого запроса свой, а константа собралась бы
# один раз при импорте — на языке, который случайно стоял в тот момент.
def agent_not_found() -> dict[str, str]:
return {"detail": t("ai.agent_not_found")}
def _validation_detail(error: ValidationError) -> str:
@@ -157,7 +162,7 @@ class AgentCardDetailView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
from chatballs.ai.agent_card import ensure_channel_agent
ensure_channel_agent(channel)
@@ -167,7 +172,7 @@ class AgentCardDetailView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
body = request.data if isinstance(request.data, dict) else {}
try:
channel = update_agent_card(
@@ -182,7 +187,7 @@ class AgentCardDetailView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
name = channel.name
try:
delete_agent_card(context=request.tenant_context, channel=channel)
@@ -201,7 +206,7 @@ class _AgentCardStatusView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
try:
channel = set_agent_card_active(
context=request.tenant_context,
@@ -234,10 +239,10 @@ class AgentCardTestChatView(APIView):
capability="ai.view",
)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
message = str(request.data.get("message", "")).strip()
if not message:
return Response({"detail": "Пустое сообщение"}, status=400)
return Response({"detail": t("ai.empty_message")}, status=400)
history = request.data.get("history") or []
if not isinstance(history, list):
return Response({"detail": "history must be a list"}, status=400)
@@ -263,7 +268,7 @@ class AgentCardConnectionsView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
data = request.data if isinstance(request.data, dict) else {}
integration_id = data.get("integrationId")
if isinstance(integration_id, bool) or not isinstance(integration_id, int):
@@ -305,7 +310,7 @@ class AgentCardConnectionDetailView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
try:
channel_services.unbind_connection(
context=request.tenant_context,
+2 -1
View File
@@ -22,6 +22,7 @@ from chatballs.ai.provider.base import (
LLMProvider,
ProviderError,
)
from chatballs.i18n import t
EMBEDDING_DIM = 16
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
@@ -131,4 +132,4 @@ class DemoProvider(LLMProvider):
]
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
raise ProviderError("Расшифровка голосовых недоступна в демо-провайдере: подключите OpenRouter или совместимый провайдер в «Настройках».")
raise ProviderError(t("ai.demo_no_transcription"))
@@ -4,6 +4,7 @@ from django.core.exceptions import ImproperlyConfigured
from chatballs.ai.provider import routing
from chatballs.ai.provider.base import LLMProvider, ProviderError
from chatballs.ai.provider.local import LocalProvider
from chatballs.i18n import t
def _test_provider() -> LLMProvider:
@@ -26,6 +27,6 @@ def get_provider(*, channel=None) -> LLMProvider:
if channel is None:
raise ProviderError(
"AI-провайдер не настроен: вызов без канала не может выбрать интеграцию"
t("ai.provider_not_configured")
)
return routing.resolve_provider(channel)
@@ -1,5 +1,6 @@
from chatballs.ai.provider import openai_http
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
from chatballs.i18n import t
class OpenRouterProvider(LLMProvider):
@@ -70,5 +71,5 @@ class OpenRouterProvider(LLMProvider):
raise ProviderError(f"Расшифровка не удалась: {error}") from error
text = str(payload.get("text") or "").strip()
if not text:
raise ProviderError("Провайдер вернул пустую расшифровку")
raise ProviderError(t("ai.empty_transcript"))
return text
+2 -1
View File
@@ -11,6 +11,7 @@ from chatballs.ai.models import (
)
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.channels.models import Channel
from chatballs.i18n import t
from chatballs.tenancy.context import TenantContext
@@ -183,7 +184,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
raise ValidationError({"agent": "Archived AI agent cannot change state"})
if is_active and locked.provider_integration_id is None:
raise ValidationError(
{"providerIntegrationId": "Для запуска AI выберите провайдера организации"}
{"providerIntegrationId": t("ai.pick_provider_first")}
)
locked.status = target_status
locked.lifecycle_version += 1
+3 -1
View File
@@ -25,6 +25,8 @@ from typing import Any
from django.db.models import Q, QuerySet
from rest_framework.exceptions import ValidationError
from chatballs.i18n import t
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
DEFAULT_WINDOW_SIZE = 50
@@ -192,7 +194,7 @@ def window(
выпадать из ленты или повторяться на границе окна.
"""
if not keys:
raise ValueError("окно требует хотя бы один ключ сортировки")
raise ValueError(t("api.window_needs_sort_key"))
ordered = queryset.order_by(*[key.ordering for key in keys])
if after is not None:
condition = _after_cursor(ordered, keys, after)
+31 -2
View File
@@ -11,7 +11,8 @@ from chatballs.calls.models import (
CallStatus,
ParticipantSide,
)
from chatballs.conversations.models import Message, MessageAuthor
from chatballs.conversations.models import Message, MessageAuthor, SystemEvent
from chatballs.i18n import t
ALLOWED_TRANSITIONS = {
CallStatus.REQUESTED: {
@@ -39,6 +40,29 @@ def _format_duration(seconds: int) -> str:
return f"{seconds // 60:02d}:{seconds % 60:02d}"
# Код события и его параметры: интерфейс собирает фразу на языке читателя, а
# текст остаётся в базе запасным вариантом и читаемой записью.
_TIMELINE_EVENTS: dict[str, str] = {
CallStatus.ACCEPTED: SystemEvent.CALL_ACCEPTED,
CallStatus.DECLINED: SystemEvent.CALL_DECLINED,
CallStatus.CANCELLED: SystemEvent.CALL_CANCELLED,
CallStatus.MISSED: SystemEvent.CALL_MISSED,
CallStatus.EXPIRED: SystemEvent.CALL_EXPIRED,
CallStatus.ACTIVE: SystemEvent.CALL_STARTED,
CallStatus.ENDED: SystemEvent.CALL_ENDED,
CallStatus.FAILED: SystemEvent.CALL_FAILED,
}
def _timeline_event(call: CallSession, target_status: str) -> tuple[str, dict]:
"""Код события и параметры для строки таймлайна."""
code = _TIMELINE_EVENTS.get(target_status, "")
if code == SystemEvent.CALL_ENDED and call.duration_seconds is not None:
return code, {"duration": _format_duration(call.duration_seconds)}
return code, {}
def _timeline_text(call: CallSession, target_status: str) -> str | None:
# Системные события звонка в timeline диалога (SPEC-CHATBALLS-0013 §13).
# Вызывается только при фактической смене статуса — retry дублей не даёт.
@@ -76,7 +100,9 @@ def transition_call(
if target_status == call.status:
return call
if target_status not in ALLOWED_TRANSITIONS.get(call.status, set()):
raise CallInvalidTransition(f"Переход {call.status} -> {target_status} запрещён")
raise CallInvalidTransition(
t("calls.transition_forbidden", current=call.status, target=target_status)
)
normalized_failure_code = failure_code.strip()
if target_status == CallStatus.FAILED and not FAILURE_CODE_PATTERN.fullmatch(normalized_failure_code):
raise CallInvalidTransition("Для FAILED требуется нормализованный failure_code")
@@ -106,9 +132,12 @@ def transition_call(
call.save(update_fields=update_fields)
timeline_text = _timeline_text(call, target_status)
if timeline_text is not None:
event, params = _timeline_event(call, target_status)
Message.objects.create(
conversation_id=call.conversation_id,
author_type=MessageAuthor.SYSTEM,
system_event=event,
system_params=params,
text=timeline_text,
)
return call
+3 -2
View File
@@ -1,6 +1,7 @@
from chatballs.calls.errors import CallAccessDenied
from chatballs.calls.models import CallSession
from chatballs.conversations.models import Conversation
from chatballs.i18n import t
from chatballs.identity.models import OrganizationMembership
from chatballs.identity.policy import require_capability
from chatballs.tenancy.context import TenantContext
@@ -8,11 +9,11 @@ from chatballs.tenancy.context import TenantContext
def ensure_conversation_call_access(*, user, conversation: Conversation) -> None:
if not require_capability(user, "conversations.call", conversation):
raise CallAccessDenied("Нет доступа к звонкам")
raise CallAccessDenied(t("calls.no_access"))
from chatballs.conversations.selectors import conversation_is_visible
if not conversation_is_visible(actor=user, conversation=conversation):
raise CallAccessDenied("Диалог вне групп сотрудника")
raise CallAccessDenied(t("calls.conversation_outside_groups"))
def ensure_call_access(*, user, call_session: CallSession) -> None:
+10 -9
View File
@@ -26,6 +26,7 @@ from chatballs.calls.tokens import (
issue_call_access_token,
verify_call_access_token,
)
from chatballs.i18n import t
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
@@ -39,7 +40,7 @@ class ResolvedInvite:
def resolve_invite(*, token: str) -> ResolvedInvite:
message = "Недействительное или истёкшее приглашение"
message = t("calls.invite_invalid")
token_hash = hash_invite_token(token)
route = call_invite_route(token_hash)
if route is None:
@@ -90,11 +91,11 @@ def _authorize_call_access(
claims = verify_call_access_token(token)
route = call_session_route(str(claims.call_session_id))
if route is None:
raise CallTokenError("Недействительный или истёкший call access token")
raise CallTokenError(t("calls.token_invalid"))
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
raise CallTokenError("Недействительный или истёкший call access token") from None
raise CallTokenError(t("calls.token_invalid")) from None
resource_context = TenantContext.for_resource(organization)
with tenant_atomic(resource_context):
try:
@@ -102,9 +103,9 @@ def _authorize_call_access(
"conversation", "conversation__channel", "initiated_by", "organization"
).get(id=claims.call_session_id, organization=organization)
except CallSession.DoesNotExist:
raise CallTokenError("Недействительный или истёкший call access token") from None
raise CallTokenError(t("calls.token_invalid")) from None
if call.status in TERMINAL_CALL_STATUSES and not allow_terminal:
raise CallTokenError("Звонок уже завершён")
raise CallTokenError(t("calls.already_ended"))
membership = None
if claims.side == ParticipantSide.STAFF:
participant = call.participants.select_related("user").filter(
@@ -133,7 +134,7 @@ def _authorize_call_access(
or call.status not in {CallStatus.REQUESTED, CallStatus.RINGING}
)
if not valid:
raise CallTokenError("Недействительный или истёкший call access token")
raise CallTokenError(t("calls.token_invalid"))
context = (
TenantContext.for_membership(membership)
if membership is not None
@@ -170,7 +171,7 @@ def _customer_call(
allow_terminal=allow_terminal,
)
if claims.side != ParticipantSide.CUSTOMER:
raise CallTokenError("Недействительный или истёкший call access token")
raise CallTokenError(t("calls.token_invalid"))
return call, context
@@ -186,7 +187,7 @@ def accept_call_by_access_token(*, token: str) -> CallSession:
organization=context.organization,
)
except CallInvalidTransition as error:
raise CallConflict("Приглашение уже нельзя принять") from error
raise CallConflict(t("calls.invite_cannot_accept")) from error
def decline_call_by_access_token(*, token: str) -> CallSession:
@@ -205,7 +206,7 @@ def decline_call_by_access_token(*, token: str) -> CallSession:
organization=context.organization,
)
except CallInvalidTransition as error:
raise CallConflict("Приглашение уже нельзя отклонить") from error
raise CallConflict(t("calls.invite_cannot_decline")) from error
def end_call_by_access_token(*, token: str) -> CallSession:
+24 -18
View File
@@ -49,9 +49,11 @@ from chatballs.conversations.models import (
LifecycleState,
Message,
MessageAuthor,
SystemEvent,
)
from chatballs.conversations.services import ClaimError, claim_locked_conversation
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.integrations.features import call_allowed
from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
@@ -80,7 +82,7 @@ class CreatedCall:
def _conversation_identity(conversation: Conversation) -> ConnectionIdentity:
if conversation.contact_id is None or conversation.connection_id is None:
raise CallConflict("У диалога нет клиентской identity для звонка")
raise CallConflict(t("calls.no_client_identity"))
identities = list(
ConnectionIdentity.objects.filter(
contact_id=conversation.contact_id,
@@ -89,29 +91,29 @@ def _conversation_identity(conversation: Conversation) -> ConnectionIdentity:
.order_by("id")[:2]
)
if len(identities) != 1:
raise CallConflict("Клиентская identity звонка отсутствует или неоднозначна")
raise CallConflict(t("calls.identity_missing_or_ambiguous"))
return identities[0]
def _check_call_creation_conflicts(*, conversation: Conversation, initiator) -> None:
if conversation.lifecycle != LifecycleState.OPEN:
raise CallConflict("Звонок можно запросить только в открытом диалоге")
raise CallConflict(t("calls.only_in_open_conversation"))
if CallSession.objects.filter(
conversation=conversation,
status__in=UNFINISHED_CALL_STATUSES,
).exists():
raise CallConflict("В диалоге уже есть незавершённый звонок")
raise CallConflict(t("calls.unfinished_call_exists"))
if CallSession.objects.filter(
initiated_by=initiator,
status__in=UNFINISHED_CALL_STATUSES,
).exists():
raise CallConflict("Сотрудник уже участвует в другом звонке")
raise CallConflict(t("calls.operator_busy"))
if (
conversation.control_mode == ControlMode.HUMAN
and conversation.assigned_operator_id
and conversation.assigned_operator_id != initiator.id
):
raise CallConflict("Диалог уже ведёт другой оператор")
raise CallConflict(t("calls.conversation_taken"))
@transaction.atomic
@@ -120,7 +122,7 @@ def create_call_request(
) -> CreatedCall:
initiator = context.actor_user
if initiator is None or context.membership is None:
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
raise CallAccessDenied(t("calls.operator_context_required"))
conversation = (
Conversation.objects.select_for_update()
.select_related("channel")
@@ -151,7 +153,7 @@ def create_call_request(
kind=kind,
)
except IntegrityError as error:
raise CallConflict("Не удалось создать второй незавершённый звонок") from error
raise CallConflict(t("calls.second_call_rejected")) from error
CallInvite.objects.create(
organization=context.organization,
@@ -181,6 +183,10 @@ def create_call_request(
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.CALL_REQUESTED,
# Вид звонка — параметр, а не часть кода: фразу собирает интерфейс, и
# «аудио» против «видео» там отдельным словом словаря.
system_params={"operator": initiator_label, "kind": call.kind},
text=f"Оператор {initiator_label} запросил {call_word}",
)
if conversation.connection.provider == IntegrationProvider.WEB:
@@ -210,13 +216,13 @@ def create_call_request(
def issue_staff_access_token(*, context: TenantContext, call_session: CallSession) -> str:
user = context.actor_user
if user is None or context.membership is None:
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
raise CallAccessDenied(t("calls.operator_context_required"))
ensure_call_access(user=context.membership, call_session=call_session)
participant_exists = call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists()
if not participant_exists:
raise CallConflict("Сотрудник не является участником звонка")
raise CallConflict(t("calls.not_a_participant"))
if call_session.status in TERMINAL_CALL_STATUSES:
raise CallConflict("Звонок уже завершён")
raise CallConflict(t("calls.already_ended"))
return issue_call_access_token(
call_session_id=call_session.id,
side=ParticipantSide.STAFF,
@@ -227,10 +233,10 @@ def issue_staff_access_token(*, context: TenantContext, call_session: CallSessio
def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession:
user = context.actor_user
if user is None or context.membership is None:
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
raise CallAccessDenied(t("calls.operator_context_required"))
ensure_call_access(user=context.membership, call_session=call_session)
if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists():
raise CallConflict("Сотрудник не является участником звонка")
raise CallConflict(t("calls.not_a_participant"))
try:
# Повторная отмена идемпотентна: transition_call вернёт звонок без изменений.
return transition_call(
@@ -239,7 +245,7 @@ def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSes
ended_by=CallEndedBy.STAFF,
)
except CallInvalidTransition as error:
raise CallConflict("Звонок уже нельзя отменить") from error
raise CallConflict(t("calls.cannot_cancel")) from error
def active_call_for_conversation(conversation: Conversation) -> CallSession | None:
@@ -272,10 +278,10 @@ def webchat_active_call(identity: ConnectionIdentity) -> CallSession | None:
def open_call_for_identity(*, identity: ConnectionIdentity) -> ResolvedInvite:
call = webchat_active_call(identity)
if call is None:
raise CallTokenError("Активное приглашение не найдено")
raise CallTokenError(t("webchat.no_active_invite"))
invite = CallInvite.objects.select_for_update().get(call_session=call)
if invite.expires_at <= timezone.now():
raise CallTokenError("Активное приглашение не найдено")
raise CallTokenError(t("webchat.no_active_invite"))
if invite.opened_at is None:
invite.opened_at = timezone.now()
invite.save(update_fields=["opened_at"])
@@ -290,7 +296,7 @@ def open_call_for_identity(*, identity: ConnectionIdentity) -> ResolvedInvite:
def decline_call_for_identity(*, identity: ConnectionIdentity) -> CallSession:
call = webchat_active_call(identity)
if call is None:
raise CallTokenError("Активное приглашение не найдено")
raise CallTokenError(t("webchat.no_active_invite"))
try:
return transition_call(
call_session_id=call.id,
@@ -298,4 +304,4 @@ def decline_call_for_identity(*, identity: ConnectionIdentity) -> CallSession:
ended_by=CallEndedBy.CUSTOMER,
)
except CallInvalidTransition as error:
raise CallConflict("Приглашение уже нельзя отклонить") from error
raise CallConflict(t("calls.invite_cannot_decline")) from error
+2 -1
View File
@@ -14,6 +14,7 @@ from django.conf import settings
from chatballs.calls.errors import CallTokenError
from chatballs.calls.models import ParticipantSide
from chatballs.i18n import t
ACCESS_TOKEN_VERSION = 1
ACCESS_TOKEN_PURPOSE = "call-access"
@@ -82,7 +83,7 @@ def issue_call_access_token(*, call_session_id: uuid.UUID, side: str, subject_id
def verify_call_access_token(token: str) -> CallAccessClaims:
message = "Недействительный или истёкший call access token"
message = t("calls.token_invalid")
if not token or "." not in token:
raise CallTokenError(message)
encoded, signature = token.rsplit(".", 1)
+13 -12
View File
@@ -25,6 +25,7 @@ from chatballs.calls.services import (
resolve_invite,
)
from chatballs.conversations.models import Conversation
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
@@ -49,16 +50,16 @@ class CallCreateView(APIView):
def post(self, request: Request, conversation_id: int) -> Response:
if "kind" not in request.data:
return Response({"detail": "Укажите тип звонка"}, status=400)
return Response({"detail": t("calls.kind_required")}, status=400)
kind = str(request.data.get("kind", ""))
if kind not in CallKind.values:
return Response({"detail": "Недопустимый тип звонка"}, status=400)
return Response({"detail": t("calls.kind_invalid")}, status=400)
try:
created = create_call_request(
context=request.tenant_context, conversation_id=conversation_id, kind=kind
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
except CallConflict as error:
@@ -95,7 +96,7 @@ class CallDetailView(APIView):
id=call_session_id, organization=request.tenant_context.organization
)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
return Response({"detail": t("calls.not_found")}, status=404)
try:
ensure_call_access(user=request.tenant_context.membership, call_session=call)
except CallAccessDenied as error:
@@ -113,7 +114,7 @@ class StaffAccessTokenView(APIView):
)
token = issue_staff_access_token(context=request.tenant_context, call_session=call)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
return Response({"detail": t("calls.not_found")}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
except CallConflict as error:
@@ -131,7 +132,7 @@ class CallCancelView(APIView):
)
cancel_call(context=request.tenant_context, call_session=call)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
return Response({"detail": t("calls.not_found")}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
except CallConflict as error:
@@ -163,7 +164,7 @@ class ConversationActiveCallView(APIView):
user=request.tenant_context.membership, conversation=conversation
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
call = active_call_for_conversation(conversation)
@@ -181,7 +182,7 @@ class InviteResolveView(APIView):
try:
resolved = resolve_invite(token=token)
except CallTokenError:
return Response({"detail": "Недействительное или истёкшее приглашение"}, status=404)
return Response({"detail": t("calls.invite_invalid")}, status=404)
return _token_response(
{
"call": public_invite_payload(
@@ -214,7 +215,7 @@ class CallAccessStateView(_CallAccessView):
try:
call = call_state_by_access_token(token=_bearer_token(request))
except CallTokenError:
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
return Response({"detail": t("calls.token_invalid")}, status=404)
return _token_response(
{"call": public_call_state_payload(call), "iceServers": ice_servers_payload()}
)
@@ -225,7 +226,7 @@ class CallAccessAcceptView(_CallAccessView):
try:
call = accept_call_by_access_token(token=_bearer_token(request))
except CallTokenError:
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
return Response({"detail": t("calls.token_invalid")}, status=404)
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
return _token_response({"call": public_call_state_payload(call)})
@@ -236,7 +237,7 @@ class CallAccessDeclineView(_CallAccessView):
try:
call = decline_call_by_access_token(token=_bearer_token(request))
except CallTokenError:
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
return Response({"detail": t("calls.token_invalid")}, status=404)
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
return _token_response({"call": public_call_state_payload(call)})
@@ -247,5 +248,5 @@ class CallAccessEndView(_CallAccessView):
try:
call = end_call_by_access_token(token=_bearer_token(request))
except CallTokenError:
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
return Response({"detail": t("calls.token_invalid")}, status=404)
return _token_response({"call": public_call_state_payload(call)})
@@ -9,6 +9,7 @@ from __future__ import annotations
from django.core.exceptions import PermissionDenied
from chatballs.i18n import t
from chatballs.identity.policy import has_capability_any_scope
from chatballs.tenancy.context import TenantContext
@@ -38,9 +39,9 @@ def require_organization_manage(context: TenantContext, *, operation: str) -> No
def require_channel_manage(context: TenantContext) -> None:
if not has_organization_capability(context, CHANNELS_MANAGE):
raise PermissionDenied("Нет прав на изменение канала")
raise PermissionDenied(t("channels.no_rights_to_change"))
def require_connections_manage(context: TenantContext) -> None:
if not has_organization_capability(context, INTEGRATIONS_MANAGE):
raise PermissionDenied("Привязка подключения требует integrations.manage")
raise PermissionDenied(t("channels.binding_needs_manage"))
+2 -1
View File
@@ -10,6 +10,7 @@ from django.db import transaction
from chatballs.channels import authorization
from chatballs.channels.models import Channel
from chatballs.i18n import t
from chatballs.identity.group_models import EmployeeGroup
from chatballs.integrations.models import Integration, IntegrationKind
from chatballs.tenancy.context import TenantContext
@@ -34,7 +35,7 @@ class ChannelHasReferences(Exception):
def __init__(self, blockers: list[dict[str, Any]]) -> None:
self.blockers = blockers
super().__init__("Канал нельзя удалить: есть связанные записи")
super().__init__(t("channels.cannot_delete_linked"))
def payload(self) -> dict[str, Any]:
return {"detail": str(self), "blockers": self.blockers}
@@ -28,6 +28,7 @@ from chatballs.conversations.serializers import message_payload
from chatballs.conversations.transports.base import guess_content_type, safe_filename
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.conversations.voice_views import _visible_message
from chatballs.i18n import t
MAX_FILE_BYTES = 20 * 1024 * 1024
# Исполняемые и скриптовые типы в чат не отправляем ни в одну сторону.
@@ -62,9 +63,9 @@ class MessageAttachmentView(ConversationViewBase):
try:
message = _visible_message(request, message_id)
except (Message.DoesNotExist, Conversation.DoesNotExist):
return Response({"detail": "Сообщение не найдено"}, status=404)
return Response({"detail": t("conversations.message_not_found")}, status=404)
if message.kind != MessageKind.FILE or not message.attachment:
return Response({"detail": "Файл недоступен"}, status=404)
return Response({"detail": t("conversations.file_unavailable")}, status=404)
return attachment_response(message, inline="inline" in request.GET)
@@ -80,15 +81,15 @@ class ConversationAttachmentView(ConversationViewBase):
try:
conversation = self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
if conversation.lifecycle != LifecycleState.OPEN:
return Response({"detail": "Диалог закрыт"}, status=409)
return Response({"detail": t("conversations.closed")}, status=409)
connection = conversation.connection
if connection is None or not transports.supports_file_send(connection):
return Response({"detail": "Файлы недоступны в этом канале"}, status=400)
return Response({"detail": t("conversations.files_unavailable_channel")}, status=400)
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "Прикрепите файл"}, status=400)
return Response({"detail": t("conversations.attach_file")}, status=400)
problem = validate_upload(upload)
if problem:
return Response({"detail": problem}, status=400)
@@ -113,7 +114,7 @@ class ConversationAttachmentView(ConversationViewBase):
caption=caption,
)
if not sent:
return Response({"detail": "Не удалось отправить файл в канал"}, status=502)
return Response({"detail": t("conversations.file_send_failed")}, status=502)
message = Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.OPERATOR,
@@ -25,6 +25,7 @@ from chatballs.conversations.models import (
from chatballs.conversations.selectors import apply_conversation_visibility
from chatballs.conversations.serializers import conversation_payload
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.i18n import t
from chatballs.identity.avatars import user_avatar_url
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.models import HumanUser, OrganizationMembership
@@ -53,10 +54,10 @@ class ConversationPriorityView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
priority = str(request.data.get("priority", "")).strip().upper()
if priority not in ConversationPriority.values:
return Response({"detail": "Неизвестный приоритет"}, status=400)
return Response({"detail": t("conversations.unknown_priority")}, status=400)
conversation.priority = priority
conversation.save(update_fields=["priority"])
self._audit(request, "priority_changed", conversation)
@@ -82,10 +83,10 @@ class ConversationContactView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
contact = conversation.contact
if contact is None:
return Response({"detail": "У диалога нет контакта"}, status=400)
return Response({"detail": t("conversations.no_contact")}, status=400)
changed: list[str] = []
for field, limit in self.LIMITS.items():
if field not in request.data:
@@ -94,7 +95,7 @@ class ConversationContactView(ConversationViewBase):
if len(value) > limit:
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
if field == "name" and not value:
return Response({"detail": "Имя контакта не может быть пустым"}, status=400)
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
setattr(contact, field, value)
changed.append(field)
if changed:
@@ -118,10 +119,10 @@ class ConversationNoteView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
note = str(request.data.get("note", ""))
if len(note) > 4000:
return Response({"detail": "Заметка длиннее 4000 символов"}, status=400)
return Response({"detail": t("conversations.note_too_long")}, status=400)
conversation.note = note
conversation.note_author = request.user if note else None
conversation.note_updated_at = timezone.now() if note else None
@@ -146,7 +147,7 @@ class ConversationLabelsView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
label_ids = request.data.get("labelIds")
if not isinstance(label_ids, list) or not all(
isinstance(item, int) for item in label_ids
@@ -159,7 +160,7 @@ class ConversationLabelsView(ConversationViewBase):
)
)
if len(labels) != len(set(label_ids)):
return Response({"detail": "Неизвестная метка"}, status=400)
return Response({"detail": t("conversations.unknown_label")}, status=400)
conversation.labels.set(labels)
return Response(
{
@@ -182,13 +183,13 @@ class ConversationArchiveView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
archived = request.data.get("archived")
if not isinstance(archived, bool):
return Response({"detail": "archived must be a boolean"}, status=400)
if not archived and not can_administer_access(request.tenant_context.membership):
return Response(
{"detail": "Восстановить диалог может только администратор"}, status=403
{"detail": t("conversations.restore_admin_only")}, status=403
)
conversation.archived_at = timezone.now() if archived else None
conversation.save(update_fields=["archived_at"])
@@ -336,9 +337,9 @@ class LabelListView(APIView):
name = str(request.data.get("name", "")).strip()
color = str(request.data.get("color", "")).strip()
if not name or len(name) > 60:
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
return Response({"detail": t("conversations.label_name_length")}, status=400)
if len(color) > 20:
return Response({"detail": "Некорректный цвет"}, status=400)
return Response({"detail": t("conversations.invalid_colour")}, status=400)
existing = ConversationLabel.objects.filter(
organization_id=request.tenant_context.organization_id, name__iexact=name
).first()
@@ -365,16 +366,16 @@ class LabelDetailView(APIView):
try:
label = self._label(request, label_id)
except ConversationLabel.DoesNotExist:
return Response({"detail": "Метка не найдена"}, status=404)
return Response({"detail": t("conversations.label_not_found")}, status=404)
if "name" in request.data:
name = str(request.data.get("name", "")).strip()
if not name or len(name) > 60:
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
return Response({"detail": t("conversations.label_name_length")}, status=400)
label.name = name
if "color" in request.data:
color = str(request.data.get("color", "")).strip()
if len(color) > 20:
return Response({"detail": "Некорректный цвет"}, status=400)
return Response({"detail": t("conversations.invalid_colour")}, status=400)
label.color = color
label.save()
return Response({"label": _label_payload(label)})
@@ -383,7 +384,7 @@ class LabelDetailView(APIView):
try:
label = self._label(request, label_id)
except ConversationLabel.DoesNotExist:
return Response({"detail": "Метка не найдена"}, status=404)
return Response({"detail": t("conversations.label_not_found")}, status=404)
label.delete()
return Response(status=204)
@@ -405,13 +406,13 @@ class ReplyTemplateListView(APIView):
title = str(request.data.get("title", "")).strip()
text = str(request.data.get("text", "")).strip()
if not title or len(title) > 120:
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
return Response({"detail": t("conversations.template_name_length")}, status=400)
if not text:
return Response({"detail": "Текст шаблона обязателен"}, status=400)
return Response({"detail": t("conversations.template_text_required")}, status=400)
if ReplyTemplate.objects.filter(
organization_id=request.tenant_context.organization_id, title__iexact=title
).exists():
return Response({"detail": "Шаблон с таким названием уже есть"}, status=409)
return Response({"detail": t("conversations.template_name_taken")}, status=409)
template = ReplyTemplate.objects.create(
organization_id=request.tenant_context.organization_id,
title=title,
@@ -433,16 +434,16 @@ class ReplyTemplateDetailView(APIView):
try:
template = self._template(request, template_id)
except ReplyTemplate.DoesNotExist:
return Response({"detail": "Шаблон не найден"}, status=404)
return Response({"detail": t("conversations.template_not_found")}, status=404)
if "title" in request.data:
title = str(request.data.get("title", "")).strip()
if not title or len(title) > 120:
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
return Response({"detail": t("conversations.template_name_length")}, status=400)
template.title = title
if "text" in request.data:
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": "Текст шаблона обязателен"}, status=400)
return Response({"detail": t("conversations.template_text_required")}, status=400)
template.text = text
template.save()
return Response({"template": _template_payload(template)})
@@ -451,6 +452,6 @@ class ReplyTemplateDetailView(APIView):
try:
template = self._template(request, template_id)
except ReplyTemplate.DoesNotExist:
return Response({"detail": "Шаблон не найден"}, status=404)
return Response({"detail": t("conversations.template_not_found")}, status=404)
template.delete()
return Response(status=204)
@@ -26,10 +26,11 @@ from chatballs.conversations.models import (
Conversation,
LifecycleState,
)
from chatballs.i18n import t
from chatballs.identity.audit_catalog import (
AUDIT_RESULT_LABELS,
audit_action_label,
audit_object_label,
audit_result_label,
)
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.identity.models import AuditEvent
@@ -154,7 +155,11 @@ def client_row(contact: Contact) -> dict:
return {
"id": contact.id,
"cid": f"CUS-{contact.id}",
"name": contact.name or "Гость",
"name": contact.name or t("conversations.guest"),
# Признак анонимного посетителя: интерфейс красит его аватар иначе.
# Раньше он выводился из самой подписи регуляркой по слову «Гость» —
# на другом языке это перестало бы работать.
"isGuest": not contact.name,
"phone": contact.phone,
"avatarUrl": contact.avatar_url,
"email": next(
@@ -278,14 +283,18 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
"action": audit_action_label(event.action) or event.action,
"object": audit_object_label(event.object_type, event.object_id),
"actor": (event.actor.full_name or event.actor.email) if event.actor_id else "Система",
"result": AUDIT_RESULT_LABELS.get(event.result, "Неизвестно"),
"result": audit_result_label(event.result),
}
)
return {
"id": contact.id,
"cid": f"CUS-{contact.id}",
"name": contact.name or "Гость",
"name": contact.name or t("conversations.guest"),
# Признак анонимного посетителя: интерфейс красит его аватар иначе.
# Раньше он выводился из самой подписи регуляркой по слову «Гость» —
# на другом языке это перестало бы работать.
"isGuest": not contact.name,
"phone": contact.phone,
"avatarUrl": contact.avatar_url,
# Поля карточки из чата (описание, компания, город).
@@ -325,7 +334,7 @@ def _merges(organization_id: int, contact: Contact) -> list[dict]:
{
"id": row.id,
"sourceId": row.source_id,
"sourceName": row.source.name or "Гость",
"sourceName": row.source.name or t("conversations.guest"),
"sourceCid": f"CUS-{row.source_id}",
"reason": row.reason,
"actor": _actor_name(row.actor),
@@ -354,7 +363,8 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
return {
"id": other.id,
"cid": f"CUS-{other.id}",
"name": other.name or "Гость",
"name": other.name or t("conversations.guest"),
"isGuest": not other.name,
"avatarUrl": other.avatar_url,
"dialogs": other.conversations.count(),
"sources": sorted({identity.connection.provider for identity in identities}),
@@ -13,6 +13,7 @@ from django.db import transaction
from django.utils import timezone
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
@@ -24,7 +25,7 @@ MIN_REASON_LENGTH = 5
def _clean_reason(reason: str) -> str:
cleaned = (reason or "").strip()
if len(cleaned) < MIN_REASON_LENGTH:
raise ValidationError("Укажите причину объединения — она попадёт в журнал действий")
raise ValidationError(t("sales.merge_reason_required"))
return cleaned[:2000]
@@ -33,14 +34,14 @@ def merge_contacts(*, organization, target_id: int, source_id: int, reason: str,
"""Перенести идентичности и диалоги source в target."""
cleaned = _clean_reason(reason)
if target_id == source_id:
raise ValidationError("Нельзя объединить контакт с самим собой")
raise ValidationError(t("sales.merge_self"))
try:
target = Contact.objects.select_for_update().get(organization=organization, id=target_id)
source = Contact.objects.select_for_update().get(organization=organization, id=source_id)
except Contact.DoesNotExist as error:
raise ValidationError("Контакт не найден") from error
raise ValidationError(t("sales.contact_not_found")) from error
if source.merged_into_id is not None or target.merged_into_id is not None:
raise ValidationError("Контакт уже объединён с другим — сначала разъедините")
raise ValidationError(t("sales.already_merged"))
identity_ids = list(ConnectionIdentity.objects.filter(contact=source).values_list("id", flat=True))
conversation_ids = list(
@@ -94,9 +95,9 @@ def revert_merge(*, organization, merge_id: int, reason: str, actor, request=Non
try:
merge = ContactMerge.objects.select_for_update().get(organization=organization, id=merge_id)
except ContactMerge.DoesNotExist as error:
raise ValidationError("Объединение не найдено") from error
raise ValidationError(t("sales.merge_not_found")) from error
if merge.reverted_at is not None:
raise ValidationError("Это объединение уже разъединено")
raise ValidationError(t("sales.already_unmerged"))
source = merge.source
target = merge.target
+35 -2
View File
@@ -28,10 +28,12 @@ from chatballs.conversations.models import (
Message,
MessageAuthor,
MessageKind,
SystemEvent,
TranscriptStatus,
)
from chatballs.conversations.transports.base import InboundMessage
from chatballs.events.models import EventOwnership, InboxEvent
from chatballs.i18n import t
from chatballs.notifications.models import NotificationAudience, NotificationType
from chatballs.notifications.services import notify, notify_management
from chatballs.tenancy.context import TenantContext
@@ -303,6 +305,14 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
audience=NotificationAudience.OPERATORS,
title=f"Новый диалог · {channel.name}",
body=f"{contact.name or 'Гость'} · {integration.provider}: {message_text[:80]}",
title_key="notifications.new_dialog",
body_key="notifications.new_dialog_body",
text_params={
"channel": channel.name,
"contact": contact.name or t("conversations.guest"),
"provider": integration.provider,
"preview": message_text[:80],
},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -318,6 +328,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
recipient_user=operator,
title=f"Новое сообщение · {contact.name or 'Гость'}",
body=message_text[:120],
title_key="notifications.new_message",
text_params={"contact": contact.name or t("conversations.guest")},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -352,7 +364,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title=f"Нужен оператор · {contact.name or 'Гость'}",
title_key="notifications.operator_needed",
text_params={"contact": contact.name or t("conversations.guest")},
body="Голосовое без расшифровки" if is_voice else message_text[:120],
body_key="notifications.voice_without_transcript" if is_voice else "",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -376,7 +391,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.last_activity_at = timezone.now()
conversation.save(update_fields=["control_mode", "expected_responder", "last_activity_at"])
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI недоступен — диалог передан оператору")
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.AI_UNAVAILABLE,
text="AI недоступен — диалог передан оператору",
)
fallback = "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится."
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
notify(
@@ -384,7 +404,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title=f"Нужен оператор · {contact.name or 'Гость'}",
title_key="notifications.operator_needed",
text_params={"contact": contact.name or t("conversations.guest")},
body="AI временно недоступен, диалог ждёт ответа",
body_key="notifications.ai_unavailable_waiting",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -395,6 +418,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
type=NotificationType.INTEGRATION_ERROR,
title=f"Ошибка AI · {channel.name}",
body="AI временно недоступен, диалог передан оператору",
title_key="notifications.ai_error",
body_key="notifications.ai_unavailable_handed_over",
text_params={"channel": channel.name},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -418,12 +444,19 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"])
if handoff:
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI передал диалог оператору")
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.AI_HANDED_OVER,
text="AI передал диалог оператору",
)
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title=f"AI передал диалог · {contact.name or 'Гость'}",
title_key="notifications.ai_handed_over",
text_params={"contact": contact.name or t("conversations.guest")},
body=ai_input[:120],
target_id=conversation.id,
source_type="Conversation",
@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-09-09 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0020_conversation_last_message_at_and_more'),
]
operations = [
migrations.AddField(
model_name='message',
name='system_event',
field=models.CharField(blank=True, choices=[('operator_took', 'Оператор перехватил диалог'), ('returned_to_ai', 'Диалог возвращён AI'), ('returned_to_queue', 'Диалог возвращён в очередь'), ('ai_unavailable', 'AI недоступен'), ('ai_handed_over', 'AI передал диалог оператору'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32),
),
migrations.AddField(
model_name='message',
name='system_params',
field=models.JSONField(blank=True, default=dict),
),
]
@@ -257,6 +257,33 @@ class MessageAuthor(models.TextChoices):
SYSTEM = "SYSTEM", "Система"
class SystemEvent(models.TextChoices):
"""Код системного события диалога.
Текст события раньше писался в ``text`` по-русски и оставался таким
навсегда: история — записи, а не подписи, и перевести её задним числом
нельзя. Поэтому в базу идёт код, а фразу собирает интерфейс на языке того,
кто её читает. ``text`` продолжает заполняться: он остаётся и запасным
вариантом для строк, записанных до этого поля, и тем, что видно в базе
глазами.
"""
OPERATOR_TOOK = "operator_took", "Оператор перехватил диалог"
RETURNED_TO_AI = "returned_to_ai", "Диалог возвращён AI"
RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь"
AI_UNAVAILABLE = "ai_unavailable", "AI недоступен"
AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору"
CALL_REQUESTED = "call_requested", "Запрошен звонок"
CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение"
CALL_DECLINED = "call_declined", "Клиент отклонил приглашение"
CALL_CANCELLED = "call_cancelled", "Приглашение отменено"
CALL_MISSED = "call_missed", "Звонок пропущен"
CALL_EXPIRED = "call_expired", "Приглашение истекло"
CALL_STARTED = "call_started", "Звонок начался"
CALL_ENDED = "call_ended", "Звонок завершён"
CALL_FAILED = "call_failed", "Звонок не состоялся"
class MessageKind(models.TextChoices):
TEXT = "", "Текст"
CONTACT_REQUEST = "contact_request", "Запрос контакта"
@@ -300,6 +327,11 @@ class Message(TenantRelationModel):
# телефона), полученный контакт. Пустая строка = текст.
kind = models.CharField(max_length=32, choices=MessageKind.choices, default=MessageKind.TEXT, blank=True)
text = models.TextField(blank=True)
# Системное событие: код и его параметры (имя оператора, длительность).
# Пустой код — обычное сообщение либо системная запись, сделанная до
# появления поля; такие показываются по сохранённому тексту.
system_event = models.CharField(max_length=32, choices=SystemEvent.choices, blank=True, default="")
system_params = models.JSONField(default=dict, blank=True)
# Санитизированный HTML входящего email. Остальные транспорты и исходящие
# ответы используют plain text.
content_html = models.TextField(blank=True)
@@ -8,6 +8,7 @@ from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
from chatballs.conversations.models import Contact
from chatballs.conversations.stats import sales_overview_stats
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import EmployeeRole
@@ -41,14 +42,14 @@ class ClientDetailView(ConversationViewBase):
try:
return Response({"client": client_detail(self._org(request).id, contact_id)})
except Contact.DoesNotExist:
return Response({"detail": "Клиент не найден"}, status=404)
return Response({"detail": t("sales.client_not_found")}, status=404)
def patch(self, request: Request, contact_id: int) -> Response:
organization = self._org(request)
try:
contact = Contact.objects.get(id=contact_id, organization=organization)
except Contact.DoesNotExist:
return Response({"detail": "Клиент не найден"}, status=404)
return Response({"detail": t("sales.client_not_found")}, status=404)
changed: list[str] = []
for field, limit in self.LIMITS.items():
if field not in request.data:
@@ -57,7 +58,7 @@ class ClientDetailView(ConversationViewBase):
if len(value) > limit:
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
if field == "name" and not value:
return Response({"detail": "Имя контакта не может быть пустым"}, status=400)
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
setattr(contact, field, value)
changed.append(field)
if changed:
@@ -86,7 +87,7 @@ class ClientMergeView(ConversationViewBase):
def _owner_only(self, request: Request) -> Response | None:
membership = request.tenant_context.membership
if membership is None or membership.role != EmployeeRole.OWNER:
return Response({"detail": "Объединять контакты может только владелец"}, status=403)
return Response({"detail": t("sales.merge_owner_only")}, status=403)
return None
def post(self, request: Request, contact_id: int) -> Response:
@@ -7,11 +7,23 @@ from chatballs.conversations.models import (
MessageAuthor,
MessageKind,
)
from chatballs.i18n import t
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import IntegrationProvider
def _system_text(message: Message) -> str:
params = message.system_params or {}
# Вид звонка приходит кодом (AUDIO/VIDEO): слово для него — тоже в каталоге.
if params.get("kind"):
params = {**params, "kind": t(f"calls.kind_{str(params['kind']).lower()}")}
rendered = t(f"conversations.system.{message.system_event}", **params)
# Ключа нет в каталоге — t вернул сам ключ; тогда честнее показать то, что
# записано, чем служебный код.
return message.text if rendered.startswith("conversations.system.") else rendered
def message_payload(message: Message) -> dict[str, object]:
payload = {
"id": message.id,
@@ -21,7 +33,14 @@ def message_payload(message: Message) -> dict[str, object]:
"authorName": (message.author_user.full_name or message.author_user.email) if message.author_user_id and message.author_user else "",
"authorAvatarUrl": user_avatar_url_in(message.author_user, message.organization_id) if message.author_user_id and message.author_user else None,
"kind": message.kind,
"text": message.text,
# Системное событие собирается по коду на языке запроса: историю
# диалога читают оба — и русскоязычный оператор, и англоязычный, — а
# записана она один раз. Код без перевода и записи, сделанные до его
# появления, приходят сохранённым текстом.
"text": _system_text(message) if message.system_event else message.text,
# Код нужен интерфейсу ещё и для тона строки: передача — предупреждение,
# взятие — акцент. Раньше тон угадывался регуляркой по русским словам.
"systemEvent": message.system_event,
"contentHtml": message.content_html,
"createdAt": message.created_at.isoformat(),
}
@@ -11,7 +11,9 @@ from chatballs.conversations.models import (
Message,
MessageAuthor,
MessageKind,
SystemEvent,
)
from chatballs.i18n import t
from chatballs.identity.models import EmployeeRole
from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
@@ -36,7 +38,7 @@ def _require_open(conversation: Conversation) -> None:
if conversation.lifecycle != LifecycleState.OPEN:
raise ClaimError("Диалог закрыт")
raise ClaimError(t("conversations.closed"))
@@ -76,7 +78,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
if operator is None or conversation.organization_id != context.organization_id:
raise ClaimError("Диалог недоступен")
raise ClaimError(t("conversations.unavailable"))
_require_open(conversation)
@@ -98,7 +100,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
):
raise ClaimError("Диалог уже ведёт другой оператор")
raise ClaimError(t("calls.conversation_taken"))
conversation.control_mode = ControlMode.HUMAN
@@ -140,7 +142,7 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
if agent is None or not agent.is_active:
raise ClaimError("У канала нет активного AI-агента")
raise ClaimError(t("channels.no_active_agent"))
conversation.control_mode = ControlMode.AI
@@ -150,7 +152,12 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён AI")
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.RETURNED_TO_AI,
text="Диалог возвращён AI",
)
return conversation
@@ -180,7 +187,12 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён в очередь")
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.RETURNED_TO_QUEUE,
text="Диалог возвращён в очередь",
)
return conversation
@@ -10,6 +10,7 @@ from dataclasses import dataclass, field
from django.conf import settings
from chatballs.i18n import t
from chatballs.integrations.outbound import ensure_downloadable
from chatballs.integrations.proxy import build_opener
@@ -104,7 +105,7 @@ def download_bytes(
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
data = response.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError("Файл больше допустимого размера")
raise ValueError(t("conversations.file_too_large"))
return data
+21 -20
View File
@@ -39,6 +39,7 @@ from chatballs.conversations.services import (
return_to_queue,
)
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.i18n import t
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.models import OrganizationMembership
from chatballs.identity.policy import ResourceScope, authorize, can_administer_access
@@ -58,7 +59,7 @@ class ConversationListView(ConversationViewBase):
# «Удалённые» (архив) скрыты; просмотр архива — только администратор.
if params.get("archived") == "1":
if not can_administer_access(request.tenant_context.membership):
return Response({"detail": "Архив доступен администраторам"}, status=403)
return Response({"detail": t("conversations.archive_admin_only")}, status=403)
items = items.filter(archived_at__isnull=False)
else:
items = items.filter(archived_at__isnull=True)
@@ -145,7 +146,7 @@ class ConversationDetailView(ConversationViewBase):
try:
conversation = self._conversation(request, conversation_id)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
# Открытие диалога = прочтение: двигаем персональную отметку до последнего
# сообщения (detail поллится каждые 3 с — пишем только при продвижении).
last_id = conversation.messages.order_by("-id").values_list("id", flat=True).first() or 0
@@ -174,7 +175,7 @@ class ConversationClaimView(ConversationViewBase):
try:
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
try:
conversation = claim_conversation(
context=request.tenant_context, conversation_id=conversation_id
@@ -200,7 +201,7 @@ class ConversationReleaseView(ConversationViewBase):
try:
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
try:
conversation = release_to_ai(
context=request.tenant_context, conversation_id=conversation_id
@@ -226,7 +227,7 @@ class ConversationReturnQueueView(ConversationViewBase):
try:
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
try:
conversation = return_to_queue(
context=request.tenant_context, conversation_id=conversation_id
@@ -264,7 +265,7 @@ def claim_for_reply(view: ConversationViewBase, request: Request, conversation:
ResourceScope(conversation.organization_id),
)
if conversation.assigned_operator_id != request.user.id and not manager_override:
return Response({"detail": "Диалог ведёт другой оператор"}, status=409)
return Response({"detail": t("conversations.handled_by_other")}, status=409)
return conversation
@@ -284,7 +285,7 @@ class ConversationMessageView(ConversationViewBase):
try:
conversation = self._conversation(request, conversation_id)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
params = request.query_params
limit = window_size(params, default=MESSAGE_WINDOW_SIZE)
messages = conversation_messages(conversation)
@@ -313,12 +314,12 @@ class ConversationMessageView(ConversationViewBase):
try:
conversation = self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": "Пустое сообщение"}, status=400)
return Response({"detail": t("ai.empty_message")}, status=400)
if conversation.lifecycle != LifecycleState.OPEN:
return Response({"detail": "Диалог закрыт"}, status=409)
return Response({"detail": t("conversations.closed")}, status=409)
claimed = claim_for_reply(self, request, conversation)
if isinstance(claimed, Response):
return claimed
@@ -339,13 +340,13 @@ class ConversationRequestContactView(ConversationViewBase):
try:
conversation = self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
if not conversation.contact_id or not conversation.connection_id:
return Response({"detail": "У диалога нет канала для запроса контакта"}, status=409)
return Response({"detail": t("conversations.no_channel_for_contact")}, status=409)
if conversation.lifecycle != LifecycleState.OPEN:
return Response({"detail": "Диалог закрыт"}, status=409)
return Response({"detail": t("conversations.closed")}, status=409)
if conversation.contact.phone:
return Response({"detail": "Контакт уже получен"}, status=409)
return Response({"detail": t("conversations.contact_already_received")}, status=409)
try:
message = request_contact(
context=request.tenant_context,
@@ -364,7 +365,7 @@ class ConversationCloseView(ConversationViewBase):
try:
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
try:
conversation = close_conversation(
context=request.tenant_context, conversation_id=conversation_id
@@ -390,7 +391,7 @@ class ConversationSpamView(ConversationViewBase):
try:
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
try:
conversation = mark_conversation_as_spam(
context=request.tenant_context, conversation_id=conversation_id
@@ -418,7 +419,7 @@ class ConversationGroupView(ConversationViewBase):
try:
conversation = self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
group_id = request.data.get("groupId")
group = None
if group_id is not None:
@@ -426,7 +427,7 @@ class ConversationGroupView(ConversationViewBase):
organization_id=conversation.organization_id, id=group_id
).first()
if group is None:
return Response({"detail": "Группа не найдена"}, status=400)
return Response({"detail": t("admin.group_not_found")}, status=400)
conversation.group = group
conversation.save(update_fields=["group"])
self._audit(request, "group_changed", conversation)
@@ -448,7 +449,7 @@ class ConversationAssigneeView(ConversationViewBase):
try:
conversation = self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
user_id = request.data.get("userId")
assignee = None
if user_id is not None:
@@ -462,7 +463,7 @@ class ConversationAssigneeView(ConversationViewBase):
.first()
)
if membership is None:
return Response({"detail": "Сотрудник не найден"}, status=400)
return Response({"detail": t("admin.employee_not_found")}, status=400)
assignee = membership.user
conversation.assigned_operator = assignee
conversation.save(update_fields=["assigned_operator"])
@@ -29,6 +29,7 @@ from chatballs.conversations.models import (
from chatballs.conversations.selectors import conversation_for_context
from chatballs.conversations.serializers import message_payload
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.i18n import t
from chatballs.integrations.features import voice_messages_allowed
from chatballs.tenancy.database import tenant_atomic
@@ -53,9 +54,9 @@ class MessageAudioView(ConversationViewBase):
try:
message = _visible_message(request, message_id)
except (Message.DoesNotExist, Conversation.DoesNotExist):
return Response({"detail": "Сообщение не найдено"}, status=404)
return Response({"detail": t("conversations.message_not_found")}, status=404)
if not message.audio:
return Response({"detail": "Аудио недоступно"}, status=404)
return Response({"detail": t("conversations.audio_unavailable")}, status=404)
return FileResponse(
message.audio.open("rb"),
content_type=message.audio_content_type or "application/octet-stream",
@@ -86,9 +87,9 @@ class MessageTranscribeView(ConversationViewBase):
try:
message = _visible_message(request, message_id)
except (Message.DoesNotExist, Conversation.DoesNotExist):
return Response({"detail": "Сообщение не найдено"}, status=404)
return Response({"detail": t("conversations.message_not_found")}, status=404)
if message.kind != MessageKind.VOICE or not message.audio:
return Response({"detail": "Это не голосовое сообщение"}, status=400)
return Response({"detail": t("conversations.not_a_voice_message")}, status=400)
if message.transcript_status == TranscriptStatus.READY:
return Response({"message": message_payload(message)})
try:
@@ -97,7 +98,7 @@ class MessageTranscribeView(ConversationViewBase):
mark_transcription_failed(message)
return Response({"detail": str(error)}, status=502)
if job is None:
return Response({"detail": "Аудио недоступно"}, status=404)
return Response({"detail": t("conversations.audio_unavailable")}, status=404)
try:
transcript = run_transcription(job)
@@ -110,7 +111,7 @@ class MessageTranscribeView(ConversationViewBase):
store_transcription(message, transcript)
payload = message_payload(message)
if not transcript:
return Response({"detail": "Провайдер вернул пустую расшифровку"}, status=502)
return Response({"detail": t("ai.empty_transcript")}, status=502)
return Response({"message": payload})
@@ -129,24 +130,24 @@ class ConversationVoiceView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
if conversation.lifecycle != LifecycleState.OPEN:
return Response({"detail": "Диалог закрыт"}, status=409)
return Response({"detail": t("conversations.closed")}, status=409)
connection = conversation.connection
if connection is None or not transports.supports_voice_send(connection):
return Response(
{"detail": "Голосовые сообщения недоступны в этом канале"}, status=400
{"detail": t("conversations.voice_unavailable_channel")}, status=400
)
if not voice_messages_allowed(connection):
return Response({"detail": "Голосовые отключены для этой точки входа"}, status=400)
return Response({"detail": t("conversations.voice_off_entry_point")}, status=400)
upload = request.FILES.get("audio")
if upload is None:
return Response({"detail": "Прикрепите аудио"}, status=400)
return Response({"detail": t("conversations.attach_audio")}, status=400)
if upload.size > MAX_VOICE_BYTES:
return Response({"detail": "Аудио больше 10 МБ"}, status=400)
return Response({"detail": t("conversations.audio_too_large")}, status=400)
content_type = (upload.content_type or "audio/ogg").split(";")[0]
if content_type not in ALLOWED_AUDIO_TYPES:
return Response({"detail": "Неподдерживаемый формат аудио"}, status=400)
return Response({"detail": t("conversations.audio_format_unsupported")}, status=400)
try:
duration = max(0, int(request.data.get("duration", 0)))
except (TypeError, ValueError):
@@ -169,7 +170,7 @@ class ConversationVoiceView(ConversationViewBase):
)
if not sent:
return Response(
{"detail": "Не удалось отправить голосовое в канал"}, status=502
{"detail": t("conversations.voice_send_failed")}, status=502
)
message = Message.objects.create(
conversation=conversation,
+31
View File
@@ -0,0 +1,31 @@
"""Язык интерфейса: выбор языка запроса и каталог сообщений."""
from chatballs.i18n.catalog import current_language, t, tn
from chatballs.i18n.languages import (
DEFAULT_LANGUAGE,
ENGLISH,
INHERIT,
LANGUAGE_CODES,
LANGUAGES,
RUSSIAN,
first_chosen,
normalize_language,
parse_accept_language,
resolve_language,
)
__all__ = [
"DEFAULT_LANGUAGE",
"ENGLISH",
"INHERIT",
"LANGUAGES",
"LANGUAGE_CODES",
"RUSSIAN",
"current_language",
"first_chosen",
"normalize_language",
"parse_accept_language",
"resolve_language",
"t",
"tn",
]
+97
View File
@@ -0,0 +1,97 @@
"""Каталог сообщений и перевод строки под язык запроса.
Переводы лежат обычными питоновскими словарями, а не в .po: продукт ставят
одной командой из готового образа, и лишний бинарный шаг сборки (msgfmt) в этой
цепочке нечем оправдать. Django и DRF свои строки при этом продолжают
переводить штатно — язык запроса активируется через ``translation.activate``,
и их скомпилированные каталоги подхватываются как обычно.
Русский каталог — источник истины: ключ появляется сначала в нём. Английский
обязан повторять его набор ключей один в один, это проверяется тестом, а не
глазами. Пропущенный перевод не роняет запрос: строка выходит по-русски, и это
видно, тогда как исключение в середине ответа не видно никому.
"""
from __future__ import annotations
from typing import Final
from django.utils.translation import get_language
from chatballs.i18n.languages import DEFAULT_LANGUAGE, normalize_language
from chatballs.i18n.messages import CATALOG
# Форма множественного числа по правилам CLDR. Русскому нужны четыре («1 диалог»,
# «2 диалога», «5 диалогов», «1,5 диалога»), английскому — две, и код, который
# просто клеит «s», на русском не работает ни в одной строке.
PluralForms = dict[str, str]
Message = str | PluralForms
_OTHER: Final = "other"
def _plural_category(language: str, count: int | float) -> str:
if language == "ru":
if count != int(count):
return _OTHER
i = abs(int(count))
if i % 10 == 1 and i % 100 != 11:
return "one"
if i % 10 in (2, 3, 4) and i % 100 not in (12, 13, 14):
return "few"
return "many"
# Английский и всё, что добавят следом с двумя формами.
if count == 1 and count == int(count):
return "one"
return _OTHER
def _lookup(key: str, language: str) -> Message | None:
table = CATALOG.get(language)
if table is not None and key in table:
return table[key]
if language != DEFAULT_LANGUAGE:
fallback = CATALOG.get(DEFAULT_LANGUAGE)
if fallback is not None and key in fallback:
return fallback[key]
return None
def current_language() -> str:
"""Язык, активированный для этого запроса (или язык по умолчанию)."""
return normalize_language(get_language()) or DEFAULT_LANGUAGE
def t(key: str, /, language: str | None = None, **params: object) -> str:
"""Строка каталога на языке запроса, с подстановкой ``{имя}``.
Неизвестный ключ возвращается как есть. Это заметно в интерфейсе, но не
ломает ответ: опечатка в ключе не должна превращать сохранение настроек в
пятисотую.
"""
code = normalize_language(language) or current_language()
message = _lookup(key, code)
if message is None:
return key
if isinstance(message, dict):
message = message.get(_OTHER, key)
return message.format(**params) if params else message
def tn(key: str, count: int | float, /, language: str | None = None, **params: object) -> str:
"""Строка с числом: форма выбирается по правилам языка.
``count`` подставляется в ``{count}`` без отдельного указания, потому что
строка с числом без самого числа не нужна ни разу.
"""
code = normalize_language(language) or current_language()
message = _lookup(key, code)
if message is None:
return key
if isinstance(message, dict):
category = _plural_category(code, count)
message = message.get(category) or message.get(_OTHER) or key
return message.format(count=count, **params)
+113
View File
@@ -0,0 +1,113 @@
"""Языки интерфейса и правило, по которому запрос выбирает свой.
Язык не выводится из окружения и не задаётся переменной: его выбирает человек.
Владелец задаёт язык организации — на нём рабочее место открывается всем, кто
в неё вошёл; отдельный сотрудник может переопределить его в профиле, и тогда
организация ему не указ. До входа организации ещё нет, и язык берётся из
настроек установки: на нём показываются логин, сброс пароля и мастер первого
запуска.
"""
from __future__ import annotations
from typing import Final
RUSSIAN: Final = "ru"
ENGLISH: Final = "en"
# Порядок важен: первый язык — тот, на котором написан исходный каталог и на
# который приходится фолбэк, когда перевода нет.
LANGUAGES: Final[tuple[tuple[str, str], ...]] = (
(RUSSIAN, "Русский"),
(ENGLISH, "English"),
)
LANGUAGE_CODES: Final[tuple[str, ...]] = tuple(code for code, _ in LANGUAGES)
DEFAULT_LANGUAGE: Final = RUSSIAN
# Пустая строка в профиле сотрудника означает «как в организации», а в
# организации — «как в установке». Это не язык, а отсутствие выбора, поэтому
# отдельного значения в LANGUAGES у неё нет.
INHERIT: Final = ""
def normalize_language(value: object) -> str:
"""Привести код к поддерживаемому языку или вернуть INHERIT.
Принимает и «ru-RU», и «RU», и «ru_ru»: браузер, операционная система и
заголовок Accept-Language пишут регион по-разному, а выбор один и тот же.
Неизвестный язык — не ошибка: это просьба, которую нечем удовлетворить, и
она сводится к «как выше по цепочке».
"""
if not isinstance(value, str):
return INHERIT
code = value.strip().replace("_", "-").lower()
if not code:
return INHERIT
base = code.partition("-")[0]
return base if base in LANGUAGE_CODES else INHERIT
def parse_accept_language(header: str | None) -> str:
"""Первый поддерживаемый язык из Accept-Language, иначе INHERIT.
Заголовок разбирается по убыванию q-веса: «en;q=0.9, ru» означает русский,
хотя английский стоит первым.
"""
if not header:
return INHERIT
weighted: list[tuple[float, int, str]] = []
for position, part in enumerate(header.split(",")):
token, _, params = part.strip().partition(";")
code = normalize_language(token)
if not code:
continue
quality = 1.0
for param in params.split(";"):
key, _, raw = param.strip().partition("=")
if key.strip() == "q":
try:
quality = float(raw)
except ValueError:
quality = 0.0
weighted.append((-quality, position, code))
if not weighted:
return INHERIT
weighted.sort()
return weighted[0][2]
def first_chosen(*candidates: str) -> str:
"""Первый уровень цепочки, который язык действительно выбрал.
Возвращает INHERIT, если не выбрал ни один: вызывающий по этому признаку
решает, спрашивать ли следующий уровень. Нужно там, где следующий уровень
стоит запроса к базе, — незачем его читать, если выбор уже сделан.
"""
for candidate in candidates:
code = normalize_language(candidate)
if code:
return code
return INHERIT
def resolve_language(
*,
user_language: str = INHERIT,
organization_language: str = INHERIT,
instance_language: str = INHERIT,
accept_language: str | None = None,
) -> str:
"""Язык одного запроса: от личного выбора к общему умолчанию.
Порядок фиксированный — профиль, организация, установка, браузер. Браузер
стоит последним и работает только до входа: после входа язык человека уже
известен, и подстраиваться под ноутбук, с которого он сегодня зашёл, не
нужно.
"""
chosen = first_chosen(user_language, organization_language, instance_language)
return chosen or parse_accept_language(accept_language) or DEFAULT_LANGUAGE
@@ -0,0 +1,14 @@
"""Сборка каталога: русский — источник истины, английский обязан его повторить.
Ключ пишется как «модуль.смысл» и не повторяет саму фразу: строку правят чаще,
чем переименовывают, и ключ вида «identity.email_taken» переживает
переформулировку, а «identity.email_already_used» — нет.
"""
from __future__ import annotations
from chatballs.i18n.messages import en, ru
CATALOG: dict[str, dict[str, object]] = {"ru": ru.MESSAGES, "en": en.MESSAGES}
__all__ = ["CATALOG"]
+304
View File
@@ -0,0 +1,304 @@
"""English catalogue. Mirrors ru.py key for key (checked by tests)."""
from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Choose a logo file",
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
"admin.employee_not_found": "Operator not found",
"admin.group_name_taken": "A group with this name already exists",
"admin.group_not_found": "Group not found",
"admin.invalid_timezone": "Enter a valid time zone",
"admin.logo_not_uploaded": "No logo has been uploaded",
"admin.multi_org_self_reset": "This operator belongs to several organizations — they reset the password themselves through access recovery",
"admin.name_too_long": "The name must not exceed 255 characters",
"admin.organization_name_required": "Enter the organization name",
"admin.photo_not_found": "Photo not found",
"ai.agent_not_found": "Agent not found",
"ai.demo_no_transcription": "Voice transcription is not available on the demo provider: connect OpenRouter or a compatible provider under Settings.",
"ai.empty_message": "The message is empty",
"ai.empty_transcript": "The provider returned an empty transcript",
"ai.provider_not_configured": "No AI provider is configured: a call without a channel cannot pick an integration",
"api.window_needs_sort_key": "a window needs at least one sort key",
"calls.invite_invalid": "The invitation is not valid or has expired",
"calls.kind_audio": "audio call",
"calls.kind_invalid": "Unsupported call type",
"calls.kind_required": "Choose the call type",
"calls.kind_video": "video call",
"calls.not_found": "Call not found",
"calls.token_invalid": "The call access token is not valid or has expired",
"channels.binding_needs_manage": "Binding a connection requires integrations.manage",
"channels.cannot_delete_linked": "The channel cannot be deleted: it has related records",
"channels.no_rights_to_change": "No rights to change the channel",
"conversations.archive_admin_only": "The archive is available to administrators",
"conversations.attach_audio": "Attach audio",
"conversations.attach_file": "Attach a file",
"conversations.audio_format_unsupported": "Unsupported audio format",
"conversations.audio_too_large": "The audio is over 10 MB",
"conversations.audio_unavailable": "The audio is unavailable",
"conversations.closed": "The conversation is closed",
"conversations.contact_already_received": "The contact details have already been received",
"conversations.contact_name_empty": "The contact name cannot be empty",
"conversations.file_send_failed": "Could not send the file to the channel",
"conversations.file_too_large": "The file is larger than allowed",
"conversations.file_unavailable": "The file is unavailable",
"conversations.files_unavailable_channel": "Files are not available on this channel",
"conversations.guest": "Guest",
"conversations.handled_by_other": "Another operator is handling this conversation",
"conversations.invalid_colour": "Invalid colour",
"conversations.label_name_length": "Label name: 1-60 characters",
"conversations.label_not_found": "Label not found",
"conversations.message_not_found": "Message not found",
"conversations.no_channel_for_contact": "The conversation has no channel to request contact details through",
"conversations.no_contact": "The conversation has no contact",
"conversations.not_a_voice_message": "This is not a voice message",
"conversations.not_found": "Conversation not found",
"conversations.note_too_long": "The note is longer than 4000 characters",
"conversations.restore_admin_only": "Only an administrator can restore a conversation",
"conversations.system.ai_handed_over": "AI handed the conversation to an operator",
"conversations.system.ai_unavailable": "AI is unavailable — the conversation was handed to an operator",
"conversations.system.call_accepted": "The customer accepted the call invitation",
"conversations.system.call_cancelled": "The operator cancelled the call invitation",
"conversations.system.call_declined": "The customer declined the call invitation",
"conversations.system.call_ended": "Call ended · {duration}",
"conversations.system.call_expired": "The call invitation expired",
"conversations.system.call_failed": "The call did not go through",
"conversations.system.call_missed": "Call missed: the customer did not answer",
"conversations.system.call_requested": "{operator} requested {kind}",
"conversations.system.call_started": "The call started: the connection is up",
"conversations.system.operator_took": "{operator} took over the conversation",
"conversations.system.returned_to_ai": "The conversation was handed back to AI",
"conversations.system.returned_to_queue": "The conversation was returned to the queue",
"conversations.template_name_length": "Template name: 1-120 characters",
"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.unknown_label": "Unknown label",
"conversations.unknown_priority": "Unknown priority",
"conversations.voice_off_entry_point": "Voice messages are switched off for this entry point",
"conversations.voice_send_failed": "Could not send the voice message to the channel",
"conversations.voice_unavailable_channel": "Voice messages are not available on this channel",
"emails.initial_access_body": "Hello {name},\n\nA Chatballs account has been created for you. To set your first password, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you were not expecting this email, contact your organization's owner.",
"emails.initial_access_subject": "Your Chatballs access",
"emails.password_reset_body": "Hello {name},\n\nYou asked to reset your Chatballs password. To set a new one, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you did not ask for a reset, simply ignore this email.",
"emails.password_reset_subject": "Chatballs access recovery",
"identity.link_invalid_or_expired": "The link is not valid or has expired",
"identity.password_needs_digit": "The password must contain a digit.",
"identity.password_needs_letter": "The password must contain a letter.",
"identity.password_needs_special": "The password must contain a special character.",
"identity.setup_already_done": "The first run is already complete: the organization exists",
"notifications.ai_error": "AI error · {channel}",
"notifications.ai_handed_over": "AI handed over the conversation · {contact}",
"notifications.ai_unavailable_handed_over": "AI is temporarily unavailable; the conversation was handed to an operator",
"notifications.ai_unavailable_waiting": "AI is temporarily unavailable; the conversation is waiting for an answer",
"notifications.new_dialog": "New conversation · {channel}",
"notifications.new_dialog_body": "{contact} · {provider}: {preview}",
"notifications.new_message": "New message · {contact}",
"notifications.operator_needed": "An operator is needed · {contact}",
"notifications.voice_without_transcript": "A voice message without a transcript",
"portals.article_not_found": "Article not found",
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
"portals.file_not_found": "File not found",
"portals.helpful_must_be_boolean": "helpful must be a boolean",
"portals.invalid_article": "Invalid article",
"portals.invalid_article_data": "Invalid article data",
"portals.invalid_category": "Invalid category",
"portals.invalid_domain": "Invalid domain name",
"portals.invalid_pagination": "Invalid pagination",
"portals.invalid_revision": "Invalid revision",
"portals.invalid_section": "Invalid section",
"portals.not_found": "Portal not found",
"portals.section_not_found": "Section not found",
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
"profile.choose_photo_file": "Choose a photo file",
"profile.link_not_found": "Link not found",
"profile.notification_bot_not_found": "Notification bot not found",
"profile.photo_not_set": "No photo is set",
"profile.totp_already_on": "TOTP is already on: turn it off with your current password first",
"profile.unknown_theme": "Unknown theme",
"sales.already_merged": "This contact is already merged with another — unmerge it first",
"sales.already_unmerged": "This merge has already been undone",
"sales.client_not_found": "Customer not found",
"sales.contact_not_found": "Contact not found",
"sales.merge_not_found": "Merge not found",
"sales.merge_owner_only": "Only the owner can merge contacts",
"sales.merge_reason_required": "Give a reason for the merge — it goes into the action log",
"sales.merge_self": "A contact cannot be merged with itself",
"settings.address_required": "Enter the installation address",
"settings.enable_s3_first": "Switch on and save S3 storage first",
"settings.entry_point_not_found": "Entry point not found",
"settings.http_or_https": "http or https",
"settings.integration_not_found": "Integration not found",
"settings.invalid_address": "Invalid address",
"settings.items_list_expected": "A list of items is expected",
"settings.language_unsupported": "This language is not supported",
"settings.migration_running": "A migration is already running",
"settings.port_is_number": "The port is a number",
"settings.s3_not_configured": "S3 is not configured",
"settings.smtp_required_first": "Set the outgoing mail server first",
"settings.socks_needs_pysocks": "A SOCKS proxy needs the PySocks dependency (pip install PySocks)",
"settings.storages_missing": "django-storages[s3] is not installed",
"settings.ttl_is_seconds": "The lifetime is a number of seconds",
"settings.unknown_storage_kind": "Unknown storage type",
"webchat.invalid_phone": "Invalid phone number",
"webchat.no_active_invite": "No active invitation found",
"webchat.session_not_found": "Session not found",
"webchat.voice_off": "Voice messages are switched off",
"webchat.widget_unavailable": "The widget is unavailable",
"webchat.write_in_max": "Write on MAX",
"webchat.write_in_telegram": "Write on Telegram",
"audit.action_access_assignment_created": "Access assigned to an operator",
"audit.action_administration_communication_updated": "Voice and calls changed",
"audit.action_administration_instance_updated": "Platform settings changed",
"audit.action_administration_logo_deleted": "Organization logo removed",
"audit.action_administration_logo_updated": "Organization logo changed",
"audit.action_administration_organization_updated": "Organization details changed",
"audit.action_administration_storage_migration_requested": "File migration started",
"audit.action_administration_storage_updated": "File storage changed",
"audit.action_ai_agent_category_knowledge_selected": "Category knowledge selected for an agent",
"audit.action_ai_agent_created": "Agent created",
"audit.action_ai_agent_knowledge_attached": "Knowledge attached to an agent",
"audit.action_ai_agent_knowledge_detached": "Knowledge detached from an agent",
"audit.action_ai_agent_portal_articles_attached": "Portal articles attached to an agent",
"audit.action_ai_agent_portal_articles_detached": "Portal articles detached from an agent",
"audit.action_ai_agent_status_changed": "Agent status changed",
"audit.action_ai_knowledge_attachment_added": "Knowledge attachment added",
"audit.action_ai_knowledge_attachment_deleted": "Knowledge attachment deleted",
"audit.action_ai_knowledge_category_created": "Knowledge category created",
"audit.action_ai_knowledge_category_deleted": "Knowledge category deleted",
"audit.action_ai_knowledge_category_updated": "Knowledge category changed",
"audit.action_ai_knowledge_created": "Knowledge item added",
"audit.action_ai_knowledge_deleted": "Knowledge item deleted",
"audit.action_ai_knowledge_imported": "Knowledge imported",
"audit.action_ai_knowledge_reindexed": "Knowledge item reindexed",
"audit.action_ai_knowledge_updated": "Knowledge item changed",
"audit.action_calls_cancelled": "Call cancelled",
"audit.action_calls_requested": "Call requested",
"audit.action_channels_channel_created": "Channel created",
"audit.action_channels_channel_deleted": "Channel deleted",
"audit.action_channels_channel_updated": "Channel changed",
"audit.action_channels_connection_bound": "Connection bound to an agent",
"audit.action_contacts_merged": "Contacts merged",
"audit.action_contacts_unmerged": "Contacts unmerged",
"audit.action_conversation_contact_updated": "Conversation contact changed",
"audit.action_conversations_archived": "Conversation moved to the archive",
"audit.action_conversations_assignee_changed": "Conversation assignee changed",
"audit.action_conversations_claimed": "Conversation taken",
"audit.action_conversations_closed": "Conversation closed",
"audit.action_conversations_contact_requested": "Customer contact details requested",
"audit.action_conversations_contact_updated": "Conversation contact changed",
"audit.action_conversations_file_sent": "File sent to the conversation",
"audit.action_conversations_group_changed": "Conversation group changed",
"audit.action_conversations_marked_spam": "Conversation marked as spam",
"audit.action_conversations_priority_changed": "Conversation priority changed",
"audit.action_conversations_released_to_ai": "Conversation handed back to AI",
"audit.action_conversations_returned_to_queue": "Conversation returned to the queue",
"audit.action_conversations_unarchived": "Conversation restored from the archive",
"audit.action_conversations_voice_sent": "Voice message sent to the conversation",
"audit.action_demo_install_requested": "Demo data installation requested",
"audit.action_demo_installed": "Demo data installed",
"audit.action_demo_installed_accounts": "Demo operator accounts created",
"audit.action_demo_remove_requested": "Demo data removal requested",
"audit.action_demo_removed": "Demo data removed",
"audit.action_identity_access_assignment_created": "Access assigned to an operator",
"audit.action_identity_access_assignment_revoked": "Operator access revoked",
"audit.action_identity_access_profile_created": "Access profile created",
"audit.action_identity_access_profile_updated": "Access profile changed",
"audit.action_identity_avatar_deleted": "Profile photo removed",
"audit.action_identity_avatar_updated": "Profile photo changed",
"audit.action_identity_employee_blocked": "Operator blocked",
"audit.action_identity_employee_created": "Operator added",
"audit.action_identity_employee_groups_changed": "Operator groups changed",
"audit.action_identity_employee_password_reset": "Operator password reset",
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
"audit.action_identity_employee_role_changed": "Operator role changed",
"audit.action_identity_employee_sessions_terminated": "Operator sessions ended",
"audit.action_identity_employee_unblocked": "Operator unblocked",
"audit.action_identity_employee_updated": "Operator changed",
"audit.action_identity_group_created": "Group created",
"audit.action_identity_group_deleted": "Group deleted",
"audit.action_identity_group_updated": "Group changed",
"audit.action_identity_instance_setup_completed": "First-run wizard completed",
"audit.action_identity_login_failed": "Failed sign-in",
"audit.action_identity_login_succeeded": "Signed in",
"audit.action_identity_login_totp_required": "Two-factor code requested",
"audit.action_identity_logout": "Signed out",
"audit.action_identity_owner_bootstrapped": "Installation owner created",
"audit.action_identity_ownership_transferred": "Organization ownership transferred",
"audit.action_identity_password_reset_completed": "Password recovered",
"audit.action_identity_password_reset_failed": "Failed password reset",
"audit.action_identity_password_reset_requested": "Password reset requested",
"audit.action_identity_profile_language_changed": "Interface language changed",
"audit.action_identity_profile_password_changed": "Password changed",
"audit.action_identity_profile_sessions_revoked": "Own sessions ended",
"audit.action_identity_profile_totp_disabled": "Two-factor authentication turned off",
"audit.action_identity_profile_totp_setup_started": "Two-factor setup started",
"audit.action_identity_profile_updated": "Operator profile changed",
"audit.action_identity_temporary_password_changed": "Temporary password changed",
"audit.action_identity_totp_enabled": "Two-factor authentication turned on",
"audit.action_identity_totp_setup_failed": "Two-factor setup failed",
"audit.action_identity_totp_verify_failed": "Wrong two-factor code",
"audit.action_integrations_integration_created": "Integration added",
"audit.action_integrations_integration_deleted": "Integration deleted",
"audit.action_integrations_integration_updated": "Integration changed",
"audit.action_organization_owner_activated": "Organization owner activated",
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
"audit.action_organization_provisioned": "Organization created",
"audit.action_support_portal_articles_imported": "Portal articles imported",
"audit.action_support_portal_created": "Portal created",
"audit.action_support_portal_draft": "Portal unpublished",
"audit.action_support_portal_published": "Portal published",
"audit.category_access_assignment": "Access and operators",
"audit.category_administration": "Settings",
"audit.category_ai": "AI and knowledge",
"audit.category_calls": "Calls",
"audit.category_channels": "Channels",
"audit.category_contacts": "Contacts",
"audit.category_conversation": "Conversations",
"audit.category_conversations": "Conversations",
"audit.category_demo": "Demo data",
"audit.category_identity": "Access and operators",
"audit.category_integrations": "Integrations",
"audit.category_organization": "Organization",
"audit.category_support_portal": "Portals",
"audit.object_accessassignment": "Access assignment",
"audit.object_accessprofile": "Access profile",
"audit.object_agentcard": "Agent",
"audit.object_channel": "Channel",
"audit.object_contact": "Contact",
"audit.object_conversation": "Conversation",
"audit.object_employeegroup": "Group",
"audit.object_humanuser": "Operator",
"audit.object_instancesettings": "Installation",
"audit.object_integration": "Integration",
"audit.object_knowledge": "Knowledge item",
"audit.object_knowledgecategory": "Knowledge category",
"audit.object_organization": "Organization",
"audit.object_storagesettings": "Storage",
"audit.object_supportportal": "Portal",
"audit.result_denied": "Denied",
"audit.result_failed": "Failed",
"audit.result_success": "Succeeded",
"audit.category_other": "Other",
"audit.result_unknown": "Unknown",
"ai.agent_code_collision": "Could not find a unique agent code",
"ai.pick_provider_first": "Pick the organization's provider to start AI",
"calls.already_ended": "The call has already ended",
"calls.cannot_cancel": "The call can no longer be cancelled",
"calls.conversation_outside_groups": "The conversation is outside the operator's groups",
"calls.conversation_taken": "Another operator is already handling this conversation",
"calls.identity_missing_or_ambiguous": "The call's customer identity is missing or ambiguous",
"calls.invite_cannot_accept": "The invitation can no longer be accepted",
"calls.invite_cannot_decline": "The invitation can no longer be declined",
"calls.no_access": "No access to calls",
"calls.no_client_identity": "The conversation has no customer identity to call",
"calls.not_a_participant": "The operator is not a participant of this call",
"calls.only_in_open_conversation": "A call can only be requested in an open conversation",
"calls.operator_busy": "The operator is already on another call",
"calls.operator_context_required": "A call needs an operator context",
"calls.second_call_rejected": "A second unfinished call could not be created",
"calls.transition_forbidden": "Transition {current} -> {target} is not allowed",
"calls.unfinished_call_exists": "The conversation already has an unfinished call",
"channels.no_active_agent": "The channel has no active AI agent",
"conversations.unavailable": "The conversation is unavailable",
}
+308
View File
@@ -0,0 +1,308 @@
"""Русский каталог — исходный. Ключ появляется здесь, потом в en.py.
Ключи сгруппированы по модулю, которому принадлежит строка, и внутри группы
идут по алфавиту: искать строку приходится по коду, а не по фразе.
"""
from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Выберите файл логотипа",
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
"admin.employee_not_found": "Сотрудник не найден",
"admin.group_name_taken": "Группа с таким именем уже есть",
"admin.group_not_found": "Группа не найдена",
"admin.invalid_timezone": "Укажите корректный часовой пояс",
"admin.logo_not_uploaded": "Логотип не загружен",
"admin.multi_org_self_reset": "Сотрудник состоит в нескольких организациях — пароль сбрасывает он сам через восстановление доступа",
"admin.name_too_long": "Название не должно превышать 255 символов",
"admin.organization_name_required": "Укажите название организации",
"admin.photo_not_found": "Фото не найдено",
"ai.agent_not_found": "Агент не найден",
"ai.demo_no_transcription": "Расшифровка голосовых недоступна в демо-провайдере: подключите OpenRouter или совместимый провайдер в «Настройках».",
"ai.empty_message": "Пустое сообщение",
"ai.empty_transcript": "Провайдер вернул пустую расшифровку",
"ai.provider_not_configured": "AI-провайдер не настроен: вызов без канала не может выбрать интеграцию",
"api.window_needs_sort_key": "окно требует хотя бы один ключ сортировки",
"calls.invite_invalid": "Недействительное или истёкшее приглашение",
"calls.kind_audio": "аудиозвонок",
"calls.kind_invalid": "Недопустимый тип звонка",
"calls.kind_required": "Укажите тип звонка",
"calls.kind_video": "видеозвонок",
"calls.not_found": "Звонок не найден",
"calls.token_invalid": "Недействительный или истёкший call access token",
"channels.binding_needs_manage": "Привязка подключения требует integrations.manage",
"channels.cannot_delete_linked": "Канал нельзя удалить: есть связанные записи",
"channels.no_rights_to_change": "Нет прав на изменение канала",
"conversations.archive_admin_only": "Архив доступен администраторам",
"conversations.attach_audio": "Прикрепите аудио",
"conversations.attach_file": "Прикрепите файл",
"conversations.audio_format_unsupported": "Неподдерживаемый формат аудио",
"conversations.audio_too_large": "Аудио больше 10 МБ",
"conversations.audio_unavailable": "Аудио недоступно",
"conversations.closed": "Диалог закрыт",
"conversations.contact_already_received": "Контакт уже получен",
"conversations.contact_name_empty": "Имя контакта не может быть пустым",
"conversations.file_send_failed": "Не удалось отправить файл в канал",
"conversations.file_too_large": "Файл больше допустимого размера",
"conversations.file_unavailable": "Файл недоступен",
"conversations.files_unavailable_channel": "Файлы недоступны в этом канале",
"conversations.guest": "Гость",
"conversations.handled_by_other": "Диалог ведёт другой оператор",
"conversations.invalid_colour": "Некорректный цвет",
"conversations.label_name_length": "Название метки: 1-60 символов",
"conversations.label_not_found": "Метка не найдена",
"conversations.message_not_found": "Сообщение не найдено",
"conversations.no_channel_for_contact": "У диалога нет канала для запроса контакта",
"conversations.no_contact": "У диалога нет контакта",
"conversations.not_a_voice_message": "Это не голосовое сообщение",
"conversations.not_found": "Диалог не найден",
"conversations.note_too_long": "Заметка длиннее 4000 символов",
"conversations.restore_admin_only": "Восстановить диалог может только администратор",
"conversations.system.ai_handed_over": "AI передал диалог оператору",
"conversations.system.ai_unavailable": "AI недоступен — диалог передан оператору",
"conversations.system.call_accepted": "Клиент принял приглашение на звонок",
"conversations.system.call_cancelled": "Сотрудник отменил приглашение на звонок",
"conversations.system.call_declined": "Клиент отклонил приглашение на звонок",
"conversations.system.call_ended": "Звонок завершён · {duration}",
"conversations.system.call_expired": "Приглашение на звонок истекло",
"conversations.system.call_failed": "Звонок не состоялся",
"conversations.system.call_missed": "Звонок пропущен: клиент не ответил",
"conversations.system.call_requested": "Оператор {operator} запросил {kind}",
"conversations.system.call_started": "Звонок начался: соединение установлено",
"conversations.system.operator_took": "Оператор {operator} перехватил диалог",
"conversations.system.returned_to_ai": "Диалог возвращён AI",
"conversations.system.returned_to_queue": "Диалог возвращён в очередь",
"conversations.template_name_length": "Название шаблона: 1-120 символов",
"conversations.template_name_taken": "Шаблон с таким названием уже есть",
"conversations.template_not_found": "Шаблон не найден",
"conversations.template_text_required": "Текст шаблона обязателен",
"conversations.unknown_label": "Неизвестная метка",
"conversations.unknown_priority": "Неизвестный приоритет",
"conversations.voice_off_entry_point": "Голосовые отключены для этой точки входа",
"conversations.voice_send_failed": "Не удалось отправить голосовое в канал",
"conversations.voice_unavailable_channel": "Голосовые сообщения недоступны в этом канале",
"emails.initial_access_body": "Здравствуйте, {name}.\n\nДля вас создана учётная запись Chatballs. Чтобы задать пароль первичного доступа, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации.",
"emails.initial_access_subject": "Первичный доступ к Chatballs",
"emails.password_reset_body": "Здравствуйте, {name}.\n\nВы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо.",
"emails.password_reset_subject": "Восстановление доступа к Chatballs",
"identity.link_invalid_or_expired": "Ссылка недействительна или истекла",
"identity.password_needs_digit": "Пароль должен содержать цифру.",
"identity.password_needs_letter": "Пароль должен содержать букву.",
"identity.password_needs_special": "Пароль должен содержать спецсимвол.",
"identity.setup_already_done": "Первый запуск уже выполнен: организация создана",
"notifications.ai_error": "Ошибка AI · {channel}",
"notifications.ai_handed_over": "AI передал диалог · {contact}",
"notifications.ai_unavailable_handed_over": "AI временно недоступен, диалог передан оператору",
"notifications.ai_unavailable_waiting": "AI временно недоступен, диалог ждёт ответа",
"notifications.new_dialog": "Новый диалог · {channel}",
"notifications.new_dialog_body": "{contact} · {provider}: {preview}",
"notifications.new_message": "Новое сообщение · {contact}",
"notifications.operator_needed": "Нужен оператор · {contact}",
"notifications.voice_without_transcript": "Голосовое без расшифровки",
"portals.article_not_found": "Статья не найдена",
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
"portals.file_not_found": "Файл не найден",
"portals.helpful_must_be_boolean": "helpful должен быть boolean",
"portals.invalid_article": "Некорректная статья",
"portals.invalid_article_data": "Некорректные данные статьи",
"portals.invalid_category": "Некорректная категория",
"portals.invalid_domain": "Некорректное доменное имя",
"portals.invalid_pagination": "Некорректная пагинация",
"portals.invalid_revision": "Некорректная версия",
"portals.invalid_section": "Некорректный раздел",
"portals.not_found": "Портал не найден",
"portals.section_not_found": "Раздел не найден",
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
"profile.choose_photo_file": "Выберите файл фото",
"profile.link_not_found": "Привязка не найдена",
"profile.notification_bot_not_found": "Бот уведомлений не найден",
"profile.photo_not_set": "Фото не задано",
"profile.totp_already_on": "TOTP уже включена: сначала отключите её текущим паролем",
"profile.unknown_theme": "Неизвестная тема",
"sales.already_merged": "Контакт уже объединён с другим — сначала разъедините",
"sales.already_unmerged": "Это объединение уже разъединено",
"sales.client_not_found": "Клиент не найден",
"sales.contact_not_found": "Контакт не найден",
"sales.merge_not_found": "Объединение не найдено",
"sales.merge_owner_only": "Объединять контакты может только владелец",
"sales.merge_reason_required": "Укажите причину объединения — она попадёт в журнал действий",
"sales.merge_self": "Нельзя объединить контакт с самим собой",
"settings.address_required": "Укажите адрес установки",
"settings.enable_s3_first": "Сначала включите и сохраните S3-хранилище",
"settings.entry_point_not_found": "Точка входа не найдена",
"settings.http_or_https": "http или https",
"settings.integration_not_found": "Интеграция не найдена",
"settings.invalid_address": "Некорректный адрес",
"settings.items_list_expected": "Ожидается список items",
"settings.language_unsupported": "Этот язык не поддерживается",
"settings.migration_running": "Перенос уже идёт",
"settings.port_is_number": "Порт — число",
"settings.s3_not_configured": "S3 не настроено",
"settings.smtp_required_first": "Сначала укажите сервер исходящей почты",
"settings.socks_needs_pysocks": "Для SOCKS-прокси нужна зависимость PySocks (pip install PySocks)",
"settings.storages_missing": "django-storages[s3] не установлен",
"settings.ttl_is_seconds": "Время жизни — число секунд",
"settings.unknown_storage_kind": "Неизвестный тип хранилища",
"webchat.invalid_phone": "Некорректный номер телефона",
"webchat.no_active_invite": "Активное приглашение не найдено",
"webchat.session_not_found": "Сессия не найдена",
"webchat.voice_off": "Голосовые отключены",
"webchat.widget_unavailable": "Виджет недоступен",
"webchat.write_in_max": "Написать в MAX",
"webchat.write_in_telegram": "Написать в Telegram",
"audit.action_access_assignment_created": "Назначен доступ сотруднику",
"audit.action_administration_communication_updated": "Изменены голосовые и звонки",
"audit.action_administration_instance_updated": "Изменены настройки платформы",
"audit.action_administration_logo_deleted": "Удалён логотип организации",
"audit.action_administration_logo_updated": "Изменён логотип организации",
"audit.action_administration_organization_updated": "Изменены данные организации",
"audit.action_administration_storage_migration_requested": "Запущен перенос файлов",
"audit.action_administration_storage_updated": "Изменено хранилище файлов",
"audit.action_ai_agent_category_knowledge_selected": "Выбраны знания категории для агента",
"audit.action_ai_agent_created": "Создан агент",
"audit.action_ai_agent_knowledge_attached": "Знания подключены к агенту",
"audit.action_ai_agent_knowledge_detached": "Знания отключены от агента",
"audit.action_ai_agent_portal_articles_attached": "Статьи портала подключены к агенту",
"audit.action_ai_agent_portal_articles_detached": "Статьи портала отключены от агента",
"audit.action_ai_agent_status_changed": "Изменён статус агента",
"audit.action_ai_knowledge_attachment_added": "Добавлено вложение знания",
"audit.action_ai_knowledge_attachment_deleted": "Удалено вложение знания",
"audit.action_ai_knowledge_category_created": "Создана категория знаний",
"audit.action_ai_knowledge_category_deleted": "Удалена категория знаний",
"audit.action_ai_knowledge_category_updated": "Изменена категория знаний",
"audit.action_ai_knowledge_created": "Добавлено знание",
"audit.action_ai_knowledge_deleted": "Удалено знание",
"audit.action_ai_knowledge_imported": "Знания импортированы",
"audit.action_ai_knowledge_reindexed": "Знание переиндексировано",
"audit.action_ai_knowledge_updated": "Изменено знание",
"audit.action_calls_cancelled": "Звонок отменён",
"audit.action_calls_requested": "Запрошен звонок",
"audit.action_channels_channel_created": "Создан канал",
"audit.action_channels_channel_deleted": "Удалён канал",
"audit.action_channels_channel_updated": "Изменён канал",
"audit.action_channels_connection_bound": "Подключение привязано к агенту",
"audit.action_contacts_merged": "Объединение контактов",
"audit.action_contacts_unmerged": "Разъединение контактов",
"audit.action_conversation_contact_updated": "Изменён контакт диалога",
"audit.action_conversations_archived": "Диалог отправлен в архив",
"audit.action_conversations_assignee_changed": "Изменён ответственный за диалог",
"audit.action_conversations_claimed": "Диалог взят в работу",
"audit.action_conversations_closed": "Диалог закрыт",
"audit.action_conversations_contact_requested": "Запрошены контакты клиента",
"audit.action_conversations_contact_updated": "Изменён контакт диалога",
"audit.action_conversations_file_sent": "Отправлен файл в диалог",
"audit.action_conversations_group_changed": "Изменена группа диалога",
"audit.action_conversations_marked_spam": "Диалог помечен спамом",
"audit.action_conversations_priority_changed": "Изменён приоритет диалога",
"audit.action_conversations_released_to_ai": "Диалог возвращён AI",
"audit.action_conversations_returned_to_queue": "Диалог возвращён в очередь",
"audit.action_conversations_unarchived": "Диалог возвращён из архива",
"audit.action_conversations_voice_sent": "Отправлено голосовое в диалог",
"audit.action_demo_install_requested": "Запрошена установка демо-данных",
"audit.action_demo_installed": "Демо-данные установлены",
"audit.action_demo_installed_accounts": "Созданы учётные записи демо-сотрудников",
"audit.action_demo_remove_requested": "Запрошено удаление демо-данных",
"audit.action_demo_removed": "Демо-данные удалены",
"audit.action_identity_access_assignment_created": "Назначен доступ сотруднику",
"audit.action_identity_access_assignment_revoked": "Отозван доступ сотрудника",
"audit.action_identity_access_profile_created": "Создан профиль доступа",
"audit.action_identity_access_profile_updated": "Изменён профиль доступа",
"audit.action_identity_avatar_deleted": "Удалено фото профиля",
"audit.action_identity_avatar_updated": "Изменено фото профиля",
"audit.action_identity_employee_blocked": "Сотрудник заблокирован",
"audit.action_identity_employee_created": "Добавлен сотрудник",
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
"audit.action_identity_employee_role_changed": "Изменена роль сотрудника",
"audit.action_identity_employee_sessions_terminated": "Завершены сессии сотрудника",
"audit.action_identity_employee_unblocked": "Сотрудник разблокирован",
"audit.action_identity_employee_updated": "Изменён сотрудник",
"audit.action_identity_group_created": "Создана группа",
"audit.action_identity_group_deleted": "Удалена группа",
"audit.action_identity_group_updated": "Изменена группа",
"audit.action_identity_instance_setup_completed": "Пройден мастер первого запуска",
"audit.action_identity_login_failed": "Неудачный вход",
"audit.action_identity_login_succeeded": "Вход в систему",
"audit.action_identity_login_totp_required": "Запрошен код двухфакторной проверки",
"audit.action_identity_logout": "Выход из системы",
"audit.action_identity_owner_bootstrapped": "Создан владелец установки",
"audit.action_identity_ownership_transferred": "Передано владение организацией",
"audit.action_identity_password_reset_completed": "Пароль восстановлен",
"audit.action_identity_password_reset_failed": "Неудачный сброс пароля",
"audit.action_identity_password_reset_requested": "Запрошен сброс пароля",
"audit.action_identity_profile_language_changed": "Изменён язык интерфейса",
"audit.action_identity_profile_password_changed": "Изменён пароль",
"audit.action_identity_profile_sessions_revoked": "Завершены свои сессии",
"audit.action_identity_profile_totp_disabled": "Отключена двухфакторная проверка",
"audit.action_identity_profile_totp_setup_started": "Начата настройка двухфакторной проверки",
"audit.action_identity_profile_updated": "Изменён профиль сотрудника",
"audit.action_identity_temporary_password_changed": "Сменён временный пароль",
"audit.action_identity_totp_enabled": "Включена двухфакторная проверка",
"audit.action_identity_totp_setup_failed": "Ошибка настройки двухфакторной проверки",
"audit.action_identity_totp_verify_failed": "Неверный код двухфакторной проверки",
"audit.action_integrations_integration_created": "Добавлена интеграция",
"audit.action_integrations_integration_deleted": "Удалена интеграция",
"audit.action_integrations_integration_updated": "Изменена интеграция",
"audit.action_organization_owner_activated": "Активирован владелец организации",
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
"audit.action_organization_provisioned": "Создана организация",
"audit.action_support_portal_articles_imported": "Импортированы статьи портала",
"audit.action_support_portal_created": "Создан портал",
"audit.action_support_portal_draft": "Портал снят с публикации",
"audit.action_support_portal_published": "Портал опубликован",
"audit.category_access_assignment": "Доступ и сотрудники",
"audit.category_administration": "Настройки",
"audit.category_ai": "AI и знания",
"audit.category_calls": "Звонки",
"audit.category_channels": "Каналы",
"audit.category_contacts": "Контакты",
"audit.category_conversation": "Диалоги",
"audit.category_conversations": "Диалоги",
"audit.category_demo": "Демо-данные",
"audit.category_identity": "Доступ и сотрудники",
"audit.category_integrations": "Интеграции",
"audit.category_organization": "Организация",
"audit.category_support_portal": "Порталы",
"audit.object_accessassignment": "Назначение доступа",
"audit.object_accessprofile": "Профиль доступа",
"audit.object_agentcard": "Агент",
"audit.object_channel": "Канал",
"audit.object_contact": "Контакт",
"audit.object_conversation": "Диалог",
"audit.object_employeegroup": "Группа",
"audit.object_humanuser": "Сотрудник",
"audit.object_instancesettings": "Установка",
"audit.object_integration": "Интеграция",
"audit.object_knowledge": "Знание",
"audit.object_knowledgecategory": "Категория знаний",
"audit.object_organization": "Организация",
"audit.object_storagesettings": "Хранилище",
"audit.object_supportportal": "Портал",
"audit.result_denied": "Отклонено",
"audit.result_failed": "Ошибка",
"audit.result_success": "Выполнено",
"audit.category_other": "Прочее",
"audit.result_unknown": "Неизвестно",
"ai.agent_code_collision": "Не удалось подобрать уникальный код агента",
"ai.pick_provider_first": "Для запуска AI выберите провайдера организации",
"calls.already_ended": "Звонок уже завершён",
"calls.cannot_cancel": "Звонок уже нельзя отменить",
"calls.conversation_outside_groups": "Диалог вне групп сотрудника",
"calls.conversation_taken": "Диалог уже ведёт другой оператор",
"calls.identity_missing_or_ambiguous": "Клиентская identity звонка отсутствует или неоднозначна",
"calls.invite_cannot_accept": "Приглашение уже нельзя принять",
"calls.invite_cannot_decline": "Приглашение уже нельзя отклонить",
"calls.no_access": "Нет доступа к звонкам",
"calls.no_client_identity": "У диалога нет клиентской identity для звонка",
"calls.not_a_participant": "Сотрудник не является участником звонка",
"calls.only_in_open_conversation": "Звонок можно запросить только в открытом диалоге",
"calls.operator_busy": "Сотрудник уже участвует в другом звонке",
"calls.operator_context_required": "Для звонка требуется контекст сотрудника",
"calls.second_call_rejected": "Не удалось создать второй незавершённый звонок",
"calls.transition_forbidden": "Переход {current} -> {target} запрещён",
"calls.unfinished_call_exists": "В диалоге уже есть незавершённый звонок",
"channels.no_active_agent": "У канала нет активного AI-агента",
"conversations.unavailable": "Диалог недоступен",
}
+62
View File
@@ -0,0 +1,62 @@
"""Активация языка на время одного запроса.
Стоит последней в цепочке — ближе всех к вьюхе, — потому что организацию
запроса определяет ``TenantContextMiddleware``, и до неё язык организации ещё
неизвестен. Активируется штатный механизм Django: от него зависят и наш
каталог, и сообщения DRF о невалидных полях.
"""
from __future__ import annotations
from collections.abc import Callable
from django.http import HttpRequest, HttpResponse
from django.utils import translation
from chatballs.i18n.languages import INHERIT, first_chosen, resolve_language
class LanguageMiddleware:
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
# Язык установки спрашивается последним и только если до него дошло:
# это чтение из базы, и запросу сотрудника со своим языком (или из
# организации со своим) оно не нужно вовсе.
language = first_chosen(
self._user_language(request), self._organization_language(request)
) or resolve_language(
instance_language=self._instance_language(),
accept_language=request.headers.get("Accept-Language"),
)
with translation.override(language):
request.LANGUAGE_CODE = language
response = self.get_response(request)
# Заголовок нужен кэшам и прокси: один и тот же URL отдаёт разный текст
# для разных людей, и без Vary ответ одного уедет другому.
response.setdefault("Content-Language", language)
existing = response.get("Vary", "")
if "accept-language" not in existing.lower():
response["Vary"] = f"{existing}, Accept-Language".lstrip(", ")
return response
@staticmethod
def _user_language(request: HttpRequest) -> str:
user = getattr(request, "user", None)
if user is None or not user.is_authenticated:
return INHERIT
return getattr(user, "ui_language", INHERIT) or INHERIT
@staticmethod
def _organization_language(request: HttpRequest) -> str:
context = getattr(request, "tenant_context", None)
if context is None:
return INHERIT
return getattr(context.organization, "language", INHERIT) or INHERIT
@staticmethod
def _instance_language() -> str:
from chatballs.identity.instance_settings import default_language
return default_language()
+127
View File
@@ -0,0 +1,127 @@
"""Проверки языка интерфейса.
Главная из них — совпадение наборов ключей в каталогах. Пропущенный перевод не
роняет запрос (строка выходит по-русски), и именно поэтому его легко не
заметить: без теста английский каталог отстаёт молча.
"""
from __future__ import annotations
import pathlib
import re
from django.test import SimpleTestCase, TestCase, override_settings
from chatballs.i18n import normalize_language, parse_accept_language, resolve_language, t
from chatballs.i18n.catalog import _plural_category
from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGE_CODES
from chatballs.i18n.messages import CATALOG
class CatalogTests(SimpleTestCase):
def test_every_language_has_the_same_keys(self) -> None:
russian = set(CATALOG["ru"])
for code, table in CATALOG.items():
if code == DEFAULT_LANGUAGE:
continue
self.assertEqual(
russian - set(table), set(), f"нет перевода на {code}"
)
self.assertEqual(
set(table) - russian, set(), f"лишние ключи в {code}"
)
def test_placeholders_match_between_languages(self) -> None:
"""Параметры фразы обязаны совпадать: иначе подстановка молча потеряется."""
placeholders = re.compile(r"\{(\w+)\}")
def names(message: object) -> set[str]:
if isinstance(message, dict):
return {n for form in message.values() for n in placeholders.findall(form)}
return set(placeholders.findall(str(message)))
for key, russian in CATALOG["ru"].items():
for code, table in CATALOG.items():
if code == DEFAULT_LANGUAGE:
continue
self.assertEqual(
names(russian), names(table[key]), f"{key}: расходятся параметры в {code}"
)
def test_unknown_key_returns_itself(self) -> None:
self.assertEqual(t("нет.такого.ключа"), "нет.такого.ключа")
def test_plural_rule_follows_the_language(self) -> None:
"""Форма выбирается по правилам CLDR, а не по «1 — единственное».
Строк с числом в каталоге бэкенда сейчас нет, и проверять нечего, кроме
самого правила: русскому нужны четыре формы, английскому — две, и
ошибка здесь всплыла бы на первой же фразе со счётчиком.
"""
self.assertEqual([_plural_category("ru", n) for n in (1, 2, 5, 11, 21)],
["one", "few", "many", "many", "one"])
self.assertEqual([_plural_category("en", n) for n in (1, 2)], ["one", "other"])
class LanguageResolutionTests(SimpleTestCase):
def test_normalize_accepts_region_and_case(self) -> None:
self.assertEqual(normalize_language("ru-RU"), "ru")
self.assertEqual(normalize_language("EN"), "en")
self.assertEqual(normalize_language("ru_ru"), "ru")
def test_unknown_language_is_not_a_choice(self) -> None:
# Не ошибка, а «выбора нет»: запрос сводится к языку выше по цепочке.
self.assertEqual(normalize_language("de"), "")
self.assertEqual(normalize_language(None), "")
def test_accept_language_respects_quality(self) -> None:
self.assertEqual(parse_accept_language("en;q=0.9, ru"), "ru")
self.assertEqual(parse_accept_language("en-GB,en;q=0.9"), "en")
self.assertEqual(parse_accept_language("de,fr;q=0.5"), "")
def test_personal_choice_beats_organization(self) -> None:
self.assertEqual(
resolve_language(user_language="en", organization_language="ru"), "en"
)
def test_organization_beats_installation(self) -> None:
self.assertEqual(
resolve_language(organization_language="en", instance_language="ru"), "en"
)
def test_browser_is_the_last_resort(self) -> None:
self.assertEqual(resolve_language(accept_language="en-US"), "en")
self.assertEqual(resolve_language(), DEFAULT_LANGUAGE)
class FrontendListTests(SimpleTestCase):
"""Список языков продублирован во фронтенде — он обязан совпадать.
Виджет открывается у клиента до любого запроса к API и знает свой язык сам,
поэтому список там свой. Расхождение означало бы язык, который интерфейс
предлагает, а сервер не принимает.
"""
def test_frontend_language_list_matches(self) -> None:
source = (
pathlib.Path(__file__).resolve().parents[4]
/ "packages/shared/src/i18n/index.ts"
)
if not source.exists():
# В образе бэкенда фронтенда нет: проверка имеет смысл только на
# полном чекауте, где и правят оба списка.
self.skipTest("фронтенд не смонтирован")
codes = re.findall(r'\{ code: "(\w+)", label:', source.read_text(encoding="utf-8"))
self.assertEqual(tuple(codes), LANGUAGE_CODES)
@override_settings(ROOT_URLCONF="chatballs_backend.urls")
class MiddlewareTests(TestCase):
def test_response_declares_the_language(self) -> None:
"""Ответ помечен языком и Vary: один URL отдаёт разный текст."""
response = self.client.get("/api/v1/auth/session/", HTTP_ACCEPT_LANGUAGE="en")
self.assertEqual(response["Content-Language"], "en")
self.assertIn("Accept-Language", response["Vary"])
@@ -4,12 +4,13 @@ from zoneinfo import available_timezones
from django.urls import reverse
from chatballs.i18n.languages import LANGUAGES
from chatballs.identity.audit_catalog import (
AUDIT_RESULT_LABELS,
audit_action_label,
audit_category,
audit_category_label,
audit_object_label,
audit_result_label,
)
from chatballs.identity.models import AuditEvent, Organization
@@ -42,6 +43,9 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
"name": organization.name,
"timezone": organization.timezone,
"currency": organization.currency,
# Пустая строка доезжает до интерфейса как есть: там это отдельный
# пункт «Как в установке», а не отсутствие значения.
"language": organization.language,
"updatedAt": organization_updated_at(organization),
"logoUrl": (
reverse(
@@ -58,6 +62,17 @@ def administration_timezones() -> list[str]:
return sorted(available_timezones())
def administration_languages() -> list[dict[str, str]]:
"""Языки для выпадающего списка в «Организации».
Подпись — на самом языке («Русский», «English»), а не переведённая:
человек ищет в списке свой язык, и «Русский» он узнает, даже когда
интерфейс сейчас английский.
"""
return [{"code": code, "label": label} for code, label in LANGUAGES]
def audit_event_payload(event: AuditEvent) -> dict[str, object]:
"""Событие журнала. Отдаём и код действия, и подпись: подписи может не быть
(тогда интерфейс показывает код), а код нужен для поиска в любом случае."""
@@ -78,7 +93,7 @@ def audit_event_payload(event: AuditEvent) -> dict[str, object]:
"objectType": event.object_type,
"objectId": event.object_id,
"result": event.result,
"resultLabel": AUDIT_RESULT_LABELS.get(event.result, "Неизвестно"),
"resultLabel": audit_result_label(event.result),
"sourceIp": event.source_ip or "",
"correlationId": event.correlation_id,
"details": event.payload or {},
@@ -9,6 +9,8 @@ from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from chatballs.i18n import t
from chatballs.i18n.languages import normalize_language
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
@@ -26,27 +28,36 @@ class OrganizationSettingsInput:
name: str
timezone: str
currency: str
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
# и владелец, который его не трогал, не должен получить жёсткий русский
# после того, как язык установки сменили.
language: str = ""
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
name = data.name.strip()
timezone = data.timezone.strip()
currency = data.currency.strip().upper()
language = normalize_language(data.language)
if data.language.strip() and not language:
raise ValidationError({"language": t("settings.language_unsupported")})
if not name:
raise ValidationError({"name": "Укажите название организации"})
raise ValidationError({"name": t("admin.organization_name_required")})
if len(name) > 255:
raise ValidationError({"name": "Название не должно превышать 255 символов"})
raise ValidationError({"name": t("admin.name_too_long")})
try:
ZoneInfo(timezone)
except (ValueError, ZoneInfoNotFoundError) as error:
raise ValidationError(
{"timezone": "Укажите корректный часовой пояс"}
{"timezone": t("admin.invalid_timezone")}
) from error
if currency != "RUB":
raise ValidationError(
{"currency": "Поддерживается только российский рубль (RUB)"}
{"currency": t("admin.currency_rub_only")}
)
return OrganizationSettingsInput(name=name, timezone=timezone, currency=currency)
return OrganizationSettingsInput(
name=name, timezone=timezone, currency=currency, language=language
)
@transaction.atomic
@@ -62,7 +73,8 @@ def update_organization_settings(
organization.name = clean.name
organization.timezone = clean.timezone
organization.currency = clean.currency
organization.save(update_fields=["name", "timezone", "currency"])
organization.language = clean.language
organization.save(update_fields=["name", "timezone", "currency", "language"])
return organization
@@ -84,7 +96,7 @@ def replace_organization_logo(
) -> Organization:
data = upload.read()
if not data:
raise ValidationError({"file": "Выберите файл логотипа"})
raise ValidationError({"file": t("admin.choose_logo_file")})
if len(data) > MAX_LOGO_BYTES:
raise ValidationError({"file": "Размер логотипа не должен превышать 2 МБ"})
detected = _image_type(data)
@@ -13,7 +13,9 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.i18n import t
from chatballs.identity.administration_payloads import (
administration_languages,
administration_timezones,
audit_event_payload,
organization_settings_payload,
@@ -29,6 +31,7 @@ from chatballs.identity.audit_catalog import (
AUDIT_CATEGORY_ALIASES,
AUDIT_RESULT_LABELS,
audit_categories,
audit_result_label,
)
from chatballs.identity.models import AuditEvent
@@ -58,6 +61,7 @@ class OrganizationSettingsView(APIView):
request.tenant_context.organization
),
"timezones": administration_timezones(),
"languages": administration_languages(),
}
)
@@ -71,6 +75,7 @@ class OrganizationSettingsView(APIView):
name=str(body.get("name", organization.name)),
timezone=str(body.get("timezone", organization.timezone)),
currency=str(body.get("currency", organization.currency)),
language=str(body.get("language", organization.language)),
),
)
except ValidationError as error:
@@ -101,7 +106,7 @@ class OrganizationLogoView(APIView):
def get(self, request: Request):
organization = request.tenant_context.organization
if not organization.logo:
return Response({"detail": "Логотип не загружен"}, status=404)
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
return FileResponse(
organization.logo.open("rb"),
content_type=organization.logo_content_type or "application/octet-stream",
@@ -111,7 +116,7 @@ class OrganizationLogoView(APIView):
def post(self, request: Request) -> Response:
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "Выберите файл логотипа"}, status=400)
return Response({"detail": t("admin.choose_logo_file")}, status=400)
try:
organization = replace_organization_logo(
context=request.tenant_context,
@@ -244,8 +249,8 @@ class AuditListView(APIView):
"filters": {
"categories": audit_categories(),
"results": [
{"value": key, "label": label}
for key, label in AUDIT_RESULT_LABELS.items()
{"value": key, "label": audit_result_label(key)}
for key in AUDIT_RESULT_LABELS
],
"actors": _audit_actors(base),
},
+148 -135
View File
@@ -14,22 +14,24 @@
from __future__ import annotations
from chatballs.i18n import t
# Разделы журнала: по ним фильтр и группировка. Ключ — префикс кода действия
# до первой точки.
AUDIT_CATEGORY_LABELS: dict[str, str] = {
"identity": "Доступ и сотрудники",
"access_assignment": "Доступ и сотрудники",
"administration": "Настройки",
"organization": "Организация",
"integrations": "Интеграции",
"channels": "Каналы",
"ai": "AI и знания",
"conversations": "Диалоги",
"conversation": "Диалоги",
"contacts": "Контакты",
"calls": "Звонки",
"support_portal": "Порталы",
"demo": "Демо-данные",
"identity": "audit.category_identity",
"access_assignment": "audit.category_access_assignment",
"administration": "audit.category_administration",
"organization": "audit.category_organization",
"integrations": "audit.category_integrations",
"channels": "audit.category_channels",
"ai": "audit.category_ai",
"conversations": "audit.category_conversations",
"conversation": "audit.category_conversation",
"contacts": "audit.category_contacts",
"calls": "audit.category_calls",
"support_portal": "audit.category_support_portal",
"demo": "audit.category_demo",
}
AUDIT_CATEGORY_ORDER = (
@@ -56,141 +58,149 @@ AUDIT_CATEGORY_ALIASES = {
AUDIT_ACTION_LABELS: dict[str, str] = {
# --- Доступ и сотрудники ---
"identity.login_succeeded": "Вход в систему",
"identity.login_failed": "Неудачный вход",
"identity.login_totp_required": "Запрошен код двухфакторной проверки",
"identity.logout": "Выход из системы",
"identity.password_reset_requested": "Запрошен сброс пароля",
"identity.password_reset_completed": "Пароль восстановлен",
"identity.password_reset_failed": "Неудачный сброс пароля",
"identity.temporary_password_changed": "Сменён временный пароль",
"identity.profile_updated": "Изменён профиль сотрудника",
"identity.profile_password_changed": "Изменён пароль",
"identity.profile_sessions_revoked": "Завершены свои сессии",
"identity.profile_totp_setup_started": "Начата настройка двухфакторной проверки",
"identity.profile_totp_disabled": "Отключена двухфакторная проверка",
"identity.totp_enabled": "Включена двухфакторная проверка",
"identity.totp_setup_failed": "Ошибка настройки двухфакторной проверки",
"identity.totp_verify_failed": "Неверный код двухфакторной проверки",
"identity.avatar_updated": "Изменено фото профиля",
"identity.avatar_deleted": "Удалено фото профиля",
"identity.employee_created": "Добавлен сотрудник",
"identity.employee_updated": "Изменён сотрудник",
"identity.employee_blocked": "Сотрудник заблокирован",
"identity.employee_unblocked": "Сотрудник разблокирован",
"identity.employee_role_changed": "Изменена роль сотрудника",
"identity.employee_groups_changed": "Изменены группы сотрудника",
"identity.employee_password_reset": "Сброшен пароль сотрудника",
"identity.employee_sessions_terminated": "Завершены сессии сотрудника",
"identity.employee_privileged_action_denied": "Отказано в привилегированном действии",
"identity.group_created": "Создана группа",
"identity.group_updated": "Изменена группа",
"identity.group_deleted": "Удалена группа",
"identity.ownership_transferred": "Передано владение организацией",
"identity.owner_bootstrapped": "Создан владелец установки",
"identity.instance_setup_completed": "Пройден мастер первого запуска",
"identity.access_profile_created": "Создан профиль доступа",
"identity.access_profile_updated": "Изменён профиль доступа",
"identity.access_assignment_created": "Назначен доступ сотруднику",
"identity.access_assignment_revoked": "Отозван доступ сотрудника",
"access_assignment.created": "Назначен доступ сотруднику",
"identity.login_succeeded": "audit.action_identity_login_succeeded",
"identity.login_failed": "audit.action_identity_login_failed",
"identity.login_totp_required": "audit.action_identity_login_totp_required",
"identity.logout": "audit.action_identity_logout",
"identity.password_reset_requested": "audit.action_identity_password_reset_requested",
"identity.password_reset_completed": "audit.action_identity_password_reset_completed",
"identity.password_reset_failed": "audit.action_identity_password_reset_failed",
"identity.temporary_password_changed": "audit.action_identity_temporary_password_changed",
"identity.profile_updated": "audit.action_identity_profile_updated",
"identity.profile_language_changed": "audit.action_identity_profile_language_changed",
"identity.profile_password_changed": "audit.action_identity_profile_password_changed",
"identity.profile_sessions_revoked": "audit.action_identity_profile_sessions_revoked",
"identity.profile_totp_setup_started": "audit.action_identity_profile_totp_setup_started",
"identity.profile_totp_disabled": "audit.action_identity_profile_totp_disabled",
"identity.totp_enabled": "audit.action_identity_totp_enabled",
"identity.totp_setup_failed": "audit.action_identity_totp_setup_failed",
"identity.totp_verify_failed": "audit.action_identity_totp_verify_failed",
"identity.avatar_updated": "audit.action_identity_avatar_updated",
"identity.avatar_deleted": "audit.action_identity_avatar_deleted",
"identity.employee_created": "audit.action_identity_employee_created",
"identity.employee_updated": "audit.action_identity_employee_updated",
"identity.employee_blocked": "audit.action_identity_employee_blocked",
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
"identity.employee_role_changed": "audit.action_identity_employee_role_changed",
"identity.employee_groups_changed": "audit.action_identity_employee_groups_changed",
"identity.employee_password_reset": "audit.action_identity_employee_password_reset",
"identity.employee_sessions_terminated": "audit.action_identity_employee_sessions_terminated",
"identity.employee_privileged_action_denied": "audit.action_identity_employee_privileged_action_denied",
"identity.group_created": "audit.action_identity_group_created",
"identity.group_updated": "audit.action_identity_group_updated",
"identity.group_deleted": "audit.action_identity_group_deleted",
"identity.ownership_transferred": "audit.action_identity_ownership_transferred",
"identity.owner_bootstrapped": "audit.action_identity_owner_bootstrapped",
"identity.instance_setup_completed": "audit.action_identity_instance_setup_completed",
"identity.access_profile_created": "audit.action_identity_access_profile_created",
"identity.access_profile_updated": "audit.action_identity_access_profile_updated",
"identity.access_assignment_created": "audit.action_identity_access_assignment_created",
"identity.access_assignment_revoked": "audit.action_identity_access_assignment_revoked",
"access_assignment.created": "audit.action_access_assignment_created",
# --- Настройки ---
"administration.organization_updated": "Изменены данные организации",
"administration.logo_updated": "Изменён логотип организации",
"administration.logo_deleted": "Удалён логотип организации",
"administration.communication_updated": "Изменены голосовые и звонки",
"administration.storage_updated": "Изменено хранилище файлов",
"administration.storage_migration_requested": "Запущен перенос файлов",
"administration.instance_updated": "Изменены настройки платформы",
"administration.organization_updated": "audit.action_administration_organization_updated",
"administration.logo_updated": "audit.action_administration_logo_updated",
"administration.logo_deleted": "audit.action_administration_logo_deleted",
"administration.communication_updated": "audit.action_administration_communication_updated",
"administration.storage_updated": "audit.action_administration_storage_updated",
"administration.storage_migration_requested": "audit.action_administration_storage_migration_requested",
"administration.instance_updated": "audit.action_administration_instance_updated",
# --- Организация ---
"organization.provisioned": "Создана организация",
"organization.owner_activated": "Активирован владелец организации",
"organization.owner_invitation_requested": "Отправлено приглашение владельцу",
"organization.provisioned": "audit.action_organization_provisioned",
"organization.owner_activated": "audit.action_organization_owner_activated",
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
# --- Интеграции и каналы ---
"integrations.integration_created": "Добавлена интеграция",
"integrations.integration_updated": "Изменена интеграция",
"integrations.integration_deleted": "Удалена интеграция",
"channels.channel_created": "Создан канал",
"channels.channel_updated": "Изменён канал",
"channels.channel_deleted": "Удалён канал",
"channels.connection_bound": "Подключение привязано к агенту",
"integrations.integration_created": "audit.action_integrations_integration_created",
"integrations.integration_updated": "audit.action_integrations_integration_updated",
"integrations.integration_deleted": "audit.action_integrations_integration_deleted",
"channels.channel_created": "audit.action_channels_channel_created",
"channels.channel_updated": "audit.action_channels_channel_updated",
"channels.channel_deleted": "audit.action_channels_channel_deleted",
"channels.connection_bound": "audit.action_channels_connection_bound",
# --- AI и знания ---
"ai.agent_created": "Создан агент",
"ai.agent_status_changed": "Изменён статус агента",
"ai.agent_category_knowledge_selected": "Выбраны знания категории для агента",
"ai.agent_knowledge_attached": "Знания подключены к агенту",
"ai.agent_knowledge_detached": "Знания отключены от агента",
"ai.agent_portal_articles_attached": "Статьи портала подключены к агенту",
"ai.agent_portal_articles_detached": "Статьи портала отключены от агента",
"ai.knowledge_created": "Добавлено знание",
"ai.knowledge_updated": "Изменено знание",
"ai.knowledge_deleted": "Удалено знание",
"ai.knowledge_reindexed": "Знание переиндексировано",
"ai.knowledge_imported": "Знания импортированы",
"ai.knowledge_attachment_added": "Добавлено вложение знания",
"ai.knowledge_attachment_deleted": "Удалено вложение знания",
"ai.knowledge_category_created": "Создана категория знаний",
"ai.knowledge_category_updated": "Изменена категория знаний",
"ai.knowledge_category_deleted": "Удалена категория знаний",
"ai.agent_created": "audit.action_ai_agent_created",
"ai.agent_status_changed": "audit.action_ai_agent_status_changed",
"ai.agent_category_knowledge_selected": "audit.action_ai_agent_category_knowledge_selected",
"ai.agent_knowledge_attached": "audit.action_ai_agent_knowledge_attached",
"ai.agent_knowledge_detached": "audit.action_ai_agent_knowledge_detached",
"ai.agent_portal_articles_attached": "audit.action_ai_agent_portal_articles_attached",
"ai.agent_portal_articles_detached": "audit.action_ai_agent_portal_articles_detached",
"ai.knowledge_created": "audit.action_ai_knowledge_created",
"ai.knowledge_updated": "audit.action_ai_knowledge_updated",
"ai.knowledge_deleted": "audit.action_ai_knowledge_deleted",
"ai.knowledge_reindexed": "audit.action_ai_knowledge_reindexed",
"ai.knowledge_imported": "audit.action_ai_knowledge_imported",
"ai.knowledge_attachment_added": "audit.action_ai_knowledge_attachment_added",
"ai.knowledge_attachment_deleted": "audit.action_ai_knowledge_attachment_deleted",
"ai.knowledge_category_created": "audit.action_ai_knowledge_category_created",
"ai.knowledge_category_updated": "audit.action_ai_knowledge_category_updated",
"ai.knowledge_category_deleted": "audit.action_ai_knowledge_category_deleted",
# --- Диалоги ---
"conversations.claimed": "Диалог взят в работу",
"conversations.released_to_ai": "Диалог возвращён AI",
"conversations.returned_to_queue": "Диалог возвращён в очередь",
"conversations.assignee_changed": "Изменён ответственный за диалог",
"conversations.group_changed": "Изменена группа диалога",
"conversations.priority_changed": "Изменён приоритет диалога",
"conversations.closed": "Диалог закрыт",
"conversations.archived": "Диалог отправлен в архив",
"conversations.unarchived": "Диалог возвращён из архива",
"conversations.marked_spam": "Диалог помечен спамом",
"conversations.contact_requested": "Запрошены контакты клиента",
"conversations.contact_updated": "Изменён контакт диалога",
"conversation.contact_updated": "Изменён контакт диалога",
"conversations.file_sent": "Отправлен файл в диалог",
"conversations.voice_sent": "Отправлено голосовое в диалог",
"conversations.claimed": "audit.action_conversations_claimed",
"conversations.released_to_ai": "audit.action_conversations_released_to_ai",
"conversations.returned_to_queue": "audit.action_conversations_returned_to_queue",
"conversations.assignee_changed": "audit.action_conversations_assignee_changed",
"conversations.group_changed": "audit.action_conversations_group_changed",
"conversations.priority_changed": "audit.action_conversations_priority_changed",
"conversations.closed": "audit.action_conversations_closed",
"conversations.archived": "audit.action_conversations_archived",
"conversations.unarchived": "audit.action_conversations_unarchived",
"conversations.marked_spam": "audit.action_conversations_marked_spam",
"conversations.contact_requested": "audit.action_conversations_contact_requested",
"conversations.contact_updated": "audit.action_conversations_contact_updated",
"conversation.contact_updated": "audit.action_conversation_contact_updated",
"conversations.file_sent": "audit.action_conversations_file_sent",
"conversations.voice_sent": "audit.action_conversations_voice_sent",
# --- Контакты ---
"contacts.merged": "Объединение контактов",
"contacts.unmerged": "Разъединение контактов",
"contacts.merged": "audit.action_contacts_merged",
"contacts.unmerged": "audit.action_contacts_unmerged",
# --- Звонки ---
"calls.requested": "Запрошен звонок",
"calls.cancelled": "Звонок отменён",
"calls.requested": "audit.action_calls_requested",
"calls.cancelled": "audit.action_calls_cancelled",
# --- Порталы ---
"support_portal.created": "Создан портал",
"support_portal.published": "Портал опубликован",
"support_portal.draft": "Портал снят с публикации",
"support_portal.articles_imported": "Импортированы статьи портала",
"support_portal.created": "audit.action_support_portal_created",
"support_portal.published": "audit.action_support_portal_published",
"support_portal.draft": "audit.action_support_portal_draft",
"support_portal.articles_imported": "audit.action_support_portal_articles_imported",
# --- Демо-данные ---
"demo.install_requested": "Запрошена установка демо-данных",
"demo.installed": "Демо-данные установлены",
"demo.installed_accounts": "Созданы учётные записи демо-сотрудников",
"demo.remove_requested": "Запрошено удаление демо-данных",
"demo.removed": "Демо-данные удалены",
"demo.install_requested": "audit.action_demo_install_requested",
"demo.installed": "audit.action_demo_installed",
"demo.installed_accounts": "audit.action_demo_installed_accounts",
"demo.remove_requested": "audit.action_demo_remove_requested",
"demo.removed": "audit.action_demo_removed",
}
def audit_result_label(result: str) -> str:
"""Подпись результата события или «Неизвестно» для чужого кода."""
key = AUDIT_RESULT_LABELS.get(result)
return t(key) if key else t("audit.result_unknown")
AUDIT_RESULT_LABELS = {
"SUCCESS": "Выполнено",
"DENIED": "Отклонено",
"FAILED": "Ошибка",
"SUCCESS": "audit.result_success",
"DENIED": "audit.result_denied",
"FAILED": "audit.result_failed",
}
# Что за объект тронули: тип из модели в человеческом виде. Пусто — покажем
# сам object_type, он и так читается латиницей.
AUDIT_OBJECT_TYPE_LABELS = {
"Organization": "Организация",
"HumanUser": "Сотрудник",
"EmployeeGroup": "Группа",
"Integration": "Интеграция",
"Channel": "Канал",
"Knowledge": "Знание",
"KnowledgeCategory": "Категория знаний",
"AgentCard": "Агент",
"Conversation": "Диалог",
"Contact": "Контакт",
"SupportPortal": "Портал",
"AccessProfile": "Профиль доступа",
"AccessAssignment": "Назначение доступа",
"InstanceSettings": "Установка",
"StorageSettings": "Хранилище",
"Organization": "audit.object_organization",
"HumanUser": "audit.object_humanuser",
"EmployeeGroup": "audit.object_employeegroup",
"Integration": "audit.object_integration",
"Channel": "audit.object_channel",
"Knowledge": "audit.object_knowledge",
"KnowledgeCategory": "audit.object_knowledgecategory",
"AgentCard": "audit.object_agentcard",
"Conversation": "audit.object_conversation",
"Contact": "audit.object_contact",
"SupportPortal": "audit.object_supportportal",
"AccessProfile": "audit.object_accessprofile",
"AccessAssignment": "audit.object_accessassignment",
"InstanceSettings": "audit.object_instancesettings",
"StorageSettings": "audit.object_storagesettings",
}
@@ -202,7 +212,8 @@ def audit_category(action: str) -> str:
def audit_category_label(category: str) -> str:
return AUDIT_CATEGORY_LABELS.get(category, "Прочее")
key = AUDIT_CATEGORY_LABELS.get(category)
return t(key) if key else t("audit.category_other")
def audit_action_label(action: str) -> str:
@@ -213,7 +224,8 @@ def audit_action_label(action: str) -> str:
надо дописать сюда.
"""
return AUDIT_ACTION_LABELS.get(action, "")
key = AUDIT_ACTION_LABELS.get(action)
return t(key) if key else ""
def audit_object_label(object_type: str, object_id: str) -> str:
@@ -221,7 +233,8 @@ def audit_object_label(object_type: str, object_id: str) -> str:
if not object_type:
return ""
label = AUDIT_OBJECT_TYPE_LABELS.get(object_type, object_type)
key = AUDIT_OBJECT_TYPE_LABELS.get(object_type)
label = t(key) if key else object_type
return f"{label} · {object_id}" if object_id else label
@@ -229,6 +242,6 @@ def audit_categories() -> list[dict[str, str]]:
"""Разделы для фильтра — в фиксированном порядке, а не в порядке словаря."""
return [
{"value": key, "label": AUDIT_CATEGORY_LABELS[key]}
{"value": key, "label": t(AUDIT_CATEGORY_LABELS[key])}
for key in AUDIT_CATEGORY_ORDER
]
@@ -8,6 +8,7 @@ from chatballs.identity.auth.profile import (
ChangeTemporaryPasswordView,
ProfileAppearanceView,
ProfileAvatarView,
ProfileLanguageView,
ProfilePasswordView,
ProfileRevokeOtherSessionsView,
ProfileSessionsView,
@@ -28,6 +29,7 @@ __all__ = [
"ProfileUpdateView",
"ProfileAppearanceView",
"ProfileAvatarView",
"ProfileLanguageView",
"ProfilePasswordView",
"ProfileTotpStartView",
"ProfileTotpDisableView",
@@ -1,6 +1,7 @@
from django.conf import settings
from rest_framework.request import Request
from chatballs.i18n import current_language
from chatballs.identity.avatars import own_avatar_url
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
from chatballs.identity.policy import get_effective_access
@@ -62,6 +63,12 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
"deliveryMode": settings.CHATBALLS_DELIVERY_MODE,
"uiTheme": user.ui_theme,
"uiAccent": user.ui_accent,
# Личный выбор языка: пустая строка — «как в организации». Отдельно
# отдаётся язык, на котором интерфейс открывается прямо сейчас, — он
# уже разрешён по цепочке профиль → организация → установка, и
# фронтенду не нужно повторять это правило у себя.
"uiLanguage": user.ui_language,
"language": current_language(),
"avatarUrl": own_avatar_url(user),
"memberships": memberships,
}
@@ -13,6 +13,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.event_handlers import PASSWORD_RESET_REQUESTED
from chatballs.identity.models import AuditResult, HumanUser
@@ -87,7 +88,7 @@ class PasswordResetConfirmView(APIView):
user = _user_from_reset_link(str(body.get("uid", "")), str(body.get("token", "")))
if user is None:
record_audit_event(action="identity.password_reset_failed", result=AuditResult.DENIED, request=request)
return Response({"detail": "Ссылка недействительна или истекла"}, status=400)
return Response({"detail": t("identity.link_invalid_or_expired")}, status=400)
new_password = str(body.get("newPassword", ""))
try:
@@ -2,17 +2,20 @@ from django.contrib.auth import login
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.http import FileResponse
from django.utils import timezone
from django.utils import timezone, translation
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.i18n.languages import normalize_language, resolve_language
from chatballs.identity.audit import record_audit_event
from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload
from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
from chatballs.identity.models import HumanUser
from chatballs.identity.instance_settings import default_language
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
@@ -53,7 +56,7 @@ class ProfileAvatarView(APIView):
def get(self, request: Request) -> Response | FileResponse:
user = request.user
if not user.avatar:
return Response({"detail": "Фото не задано"}, status=404)
return Response({"detail": t("profile.photo_not_set")}, status=404)
return FileResponse(
user.avatar.open("rb"),
content_type=user.avatar_content_type or "application/octet-stream",
@@ -63,7 +66,7 @@ class ProfileAvatarView(APIView):
def post(self, request: Request) -> Response:
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "Выберите файл фото"}, status=400)
return Response({"detail": t("profile.choose_photo_file")}, status=400)
replace_user_avatar(request.user, upload)
record_audit_event(
action="identity.avatar_updated",
@@ -128,7 +131,7 @@ class ProfileTotpStartView(APIView):
def post(self, request: Request) -> Response:
if request.user.totp_enabled:
return Response(
{"detail": "TOTP уже включена: сначала отключите её текущим паролем"},
{"detail": t("profile.totp_already_on")},
status=409,
)
request.user.totp_secret = ""
@@ -230,10 +233,66 @@ class ProfileAppearanceView(APIView):
theme = str(body.get("theme", request.user.ui_theme)).strip().upper()
accent = str(body.get("accent", request.user.ui_accent)).strip().lower()
if theme not in UiTheme.values:
return Response({"detail": "Неизвестная тема"}, status=400)
return Response({"detail": t("profile.unknown_theme")}, status=400)
if accent and not re.fullmatch(r"#[0-9a-f]{6}", accent):
return Response({"detail": "Акцент — HEX-цвет вида #1677ff"}, status=400)
return Response({"detail": t("profile.accent_hex")}, status=400)
request.user.ui_theme = theme
request.user.ui_accent = accent
request.user.save(update_fields=["ui_theme", "ui_accent"])
return Response({"authenticated": True, "user": _user_payload(request.user)})
class ProfileLanguageView(APIView):
"""Язык интерфейса — личная настройка сотрудника.
Пустое значение возвращает человека к языку организации: это не «русский»,
а снятие личного выбора, и после смены языка организации такой сотрудник
поедет за ней, а тот, кто выбрал язык явно, — нет.
"""
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
raw = str(request.data.get("language", "")).strip()
language = normalize_language(raw)
if raw and not language:
return Response({"detail": t("settings.language_unsupported")}, status=400)
request.user.ui_language = language
request.user.save(update_fields=["ui_language"])
record_audit_event(
action="identity.profile_language_changed",
actor=request.user,
object_type="HumanUser",
object_id=str(request.user.id),
request=request,
)
# Ответ уже на новом языке: интерфейс перерисуется по нему сразу, не
# дожидаясь следующего запроса.
resolved = resolve_language(
user_language=language,
organization_language=_request_organization_language(request),
instance_language=default_language(),
accept_language=request.headers.get("Accept-Language"),
)
with translation.override(resolved):
return Response({"authenticated": True, "user": _user_payload(request.user)})
def _request_organization_language(request: Request) -> str:
"""Язык организации запроса, если он у этого запроса вообще есть.
Профиль открывается вне организации, поэтому tenant_context здесь обычно
пуст: тогда язык берётся из единственного членства, а при нескольких —
из того, что старше, чтобы ответ не зависел от порядка выборки.
"""
context = getattr(request, "tenant_context", None)
if context is not None:
return context.organization.language or ""
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(user=request.user, blocked_at__isnull=True)
.order_by("created_at", "id")
.first()
)
return membership.organization.language if membership is not None else ""
@@ -9,6 +9,7 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import current_language
from chatballs.identity.audit import record_audit_event
from chatballs.identity.auth.common import _challenge_payload, _user_payload
from chatballs.identity.auth.totp_utils import TOTP_SESSION_KEY, TOTP_STARTED_KEY
@@ -22,7 +23,12 @@ class SessionView(APIView):
def get(self, request: Request) -> Response:
if not request.user.is_authenticated:
return Response({"authenticated": False})
# Язык установки нужен и до входа: логин и сброс пароля — экраны
# самой коробки, и на английской машине они должны открываться на
# языке установки, а не на языке браузера. Без этого фронтенд знал
# бы только про браузер и расходился бы с бэкендом, который язык
# установки учитывает.
return Response({"authenticated": False, "language": current_language()})
return Response({"authenticated": True, "user": _user_payload(request.user)})
@@ -11,6 +11,7 @@ urlpatterns = [
path("password-reset/confirm/", auth.PasswordResetConfirmView.as_view(), name="auth-password-reset-confirm"),
path("profile/update/", auth.ProfileUpdateView.as_view(), name="auth-profile-update"),
path("profile/appearance/", auth.ProfileAppearanceView.as_view(), name="auth-profile-appearance"),
path("profile/language/", auth.ProfileLanguageView.as_view(), name="auth-profile-language"),
path("profile/avatar/", auth.ProfileAvatarView.as_view(), name="auth-profile-avatar"),
path("profile/password/", auth.ProfilePasswordView.as_view(), name="auth-profile-password"),
path("profile/totp/start/", auth.ProfileTotpStartView.as_view(), name="auth-profile-totp-start"),
+2 -1
View File
@@ -16,6 +16,7 @@ from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from rest_framework.exceptions import ValidationError
from chatballs.i18n import t
from chatballs.identity.models import HumanUser
MAX_AVATAR_BYTES = 2 * 1024 * 1024
@@ -65,7 +66,7 @@ def image_type(data: bytes) -> tuple[str, str] | None:
def replace_user_avatar(user: HumanUser, upload: UploadedFile) -> HumanUser:
data = upload.read()
if not data:
raise ValidationError({"file": "Выберите файл фото"})
raise ValidationError({"file": t("profile.choose_photo_file")})
if len(data) > MAX_AVATAR_BYTES:
raise ValidationError({"file": "Размер фото не должен превышать 2 МБ"})
detected = image_type(data)
+49 -25
View File
@@ -1,9 +1,13 @@
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail
from django.utils import translation
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from chatballs.i18n import t
from chatballs.i18n.languages import resolve_language
from chatballs.identity.instance_settings import (
default_language,
email_connection,
email_from_address,
public_base_url,
@@ -19,34 +23,54 @@ def _password_setup_url(user: HumanUser) -> str:
return f"{public_base_url()}/reset-password?uid={uid}&token={token}"
def _recipient_language(user: HumanUser) -> str:
"""Язык получателя письма, а не того, кто нажал кнопку.
Приглашение сотруднику отправляет владелец, и язык запроса здесь не при
чём: письмо читает другой человек. Личный выбор получателя побеждает,
дальше — язык его организации, дальше — язык установки.
"""
membership = (
user.memberships.select_related("organization")
.filter(blocked_at__isnull=True)
.order_by("created_at", "id")
.first()
)
return resolve_language(
user_language=user.ui_language,
organization_language=membership.organization.language if membership else "",
instance_language=default_language(),
)
def send_initial_access_email(user: HumanUser) -> None:
setup_url = _password_setup_url(user)
send_mail(
subject="Первичный доступ к Chatballs",
message=(
f"Здравствуйте, {user.full_name or user.email}.\n\n"
"Для вас создана учётная запись Chatballs. Чтобы задать пароль первичного доступа, "
"перейдите по ссылке:\n"
f"{setup_url}\n\n"
"Ссылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации."
),
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
with translation.override(_recipient_language(user)):
send_mail(
subject=t("emails.initial_access_subject"),
message=t(
"emails.initial_access_body",
name=user.full_name or user.email,
url=setup_url,
),
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
def send_password_reset_email(user: HumanUser) -> None:
reset_url = _password_setup_url(user)
send_mail(
subject="Восстановление доступа к Chatballs",
message=(
f"Здравствуйте, {user.full_name or user.email}.\n\n"
"Вы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n"
f"{reset_url}\n\n"
"Ссылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо."
),
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
with translation.override(_recipient_language(user)):
send_mail(
subject=t("emails.password_reset_subject"),
message=t(
"emails.password_reset_body",
name=user.full_name or user.email,
url=reset_url,
),
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
@@ -4,6 +4,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_password import clean_password_mode, reset_employee_password
from chatballs.identity.employee_support import employee_payload, get_owned_profile
@@ -36,12 +37,7 @@ class EmployeeResetPasswordView(APIView):
# организации, когда человек работает и в другой, нельзя.
if profile.user.memberships.count() > 1:
return Response(
{
"detail": (
"Сотрудник состоит в нескольких организациях — "
"пароль сбрасывает он сам через восстановление доступа"
)
},
{"detail": t("admin.multi_org_self_reset")},
status=409,
)
password = reset_employee_password(
@@ -7,6 +7,7 @@ from rest_framework.views import APIView
from chatballs.api.pagination import page_payload, paginate
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
from chatballs.identity.employee_selectors import employees_for
@@ -50,7 +51,7 @@ class EmployeeAvatarView(APIView):
.first()
)
if membership is None or not membership.user.avatar:
return Response({"detail": "Фото не найдено"}, status=404)
return Response({"detail": t("admin.photo_not_found")}, status=404)
response = FileResponse(
membership.user.avatar.open("rb"),
content_type=membership.user.avatar_content_type or "application/octet-stream",
@@ -8,6 +8,7 @@ from rest_framework.response import Response
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.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import OrganizationMembership
@@ -81,11 +82,11 @@ class GroupListView(APIView):
return Response({"detail": members_error}, status=400)
color = _clean_color(request.data.get("color"))
if color is None:
return Response({"detail": "Некорректный цвет"}, status=400)
return Response({"detail": t("conversations.invalid_colour")}, status=400)
try:
group = EmployeeGroup.objects.create(organization=organization, name=name, color=color)
except (ValidationError, IntegrityError):
return Response({"detail": "Группа с таким именем уже есть"}, status=400)
return Response({"detail": t("admin.group_name_taken")}, status=400)
if members:
EmployeeGroupMember.objects.bulk_create(
[
@@ -124,7 +125,7 @@ class GroupDetailView(APIView):
if "color" in request.data:
color = _clean_color(request.data.get("color"))
if color is None:
return Response({"detail": "Некорректный цвет"}, status=400)
return Response({"detail": t("conversations.invalid_colour")}, status=400)
group.color = color
if "name" in request.data or "color" in request.data:
if "name" in request.data:
@@ -132,7 +133,7 @@ class GroupDetailView(APIView):
try:
group.save()
except (ValidationError, IntegrityError):
return Response({"detail": "Группа с таким именем уже есть"}, status=400)
return Response({"detail": t("admin.group_name_taken")}, status=400)
members, members_error = _resolve_members(
group.organization, request.data.get("memberIds")
)
@@ -43,6 +43,11 @@ class InstanceSettings(models.Model):
email_use_tls = models.BooleanField(default=True)
email_from = models.CharField(max_length=255, blank=True, default="")
# Язык установки: на нём открываются экраны, где организации ещё нет, —
# логин, сброс пароля, мастер первого запуска, — и он же служит умолчанием
# для организаций, которые своего языка не выбрали.
default_language = models.CharField(max_length=5, blank=True, default="ru")
# Адреса TURN-серверов для звонков через relay. Секрет сюда не пишется:
# он общий с coturn и живёт в томе секретов, чтобы не вводить его дважды.
turn_urls = models.TextField(blank=True, default="")
@@ -107,6 +112,30 @@ def accepted_hosts() -> tuple[str, ...]:
return tuple(host for host in _hosts() if host)
def default_language() -> str:
"""Язык установки: экраны до входа и умолчание для организаций.
Читается без кэша, в отличие от адреса. Кэш с TTL здесь означал бы, что
один и тот же запрос стоит то одного запроса к базе, то нуля — в зависимости
от того, попал ли он в окно, — и язык установки некоторое время оставался бы
старым в тех процессах, которые смены не видели. Цена честности — один
поиск singleton-строки по первичному ключу, и только у запросов, которые
вообще досюда дошли: у сотрудника со своим языком и у организации со своим
до этой ветки дело не доходит.
Пустая строка означает установку, где язык ещё ни разу не задавали, — и
приводит к языку по умолчанию из ``chatballs.i18n``.
"""
from chatballs.i18n.languages import normalize_language
try:
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
except Exception: # таблицы ещё нет (первые миграции)
return ""
return normalize_language(row.default_language) if row is not None else ""
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
@@ -13,6 +13,8 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.i18n import t
from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_language
from chatballs.identity.instance_settings import (
InstanceSettings,
email_connection,
@@ -30,6 +32,9 @@ def instance_payload(row: InstanceSettings) -> dict:
"publicHost": row.public_host,
"publicScheme": row.public_scheme or "http",
"publicUrl": public_base_url(),
# Язык экранов, где организации ещё нет: логин, сброс пароля, мастер.
"defaultLanguage": row.default_language or DEFAULT_LANGUAGE,
"languages": [{"code": code, "label": label} for code, label in LANGUAGES],
"updatedAt": row.updated_at,
"email": {
"host": row.email_host,
@@ -70,31 +75,43 @@ class InstanceAddressView(APIView):
raw_host = raw_host.split("//", 1)[1]
host = normalize_domain(raw_host.split("/", 1)[0].split(":", 1)[0])
if not host:
errors["publicHost"] = "Укажите адрес установки"
errors["publicHost"] = t("settings.address_required")
else:
try:
# IP-адрес — обычный случай коробки: домена может не быть вовсе.
if not _looks_like_ipv4(host):
validate_domain(host)
except ValidationError:
errors["publicHost"] = "Некорректный адрес"
errors["publicHost"] = t("settings.invalid_address")
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
if scheme not in SCHEMES:
errors["publicScheme"] = "http или https"
errors["publicScheme"] = t("settings.http_or_https")
raw_language = str(body.get("defaultLanguage", row.default_language)).strip()
language = normalize_language(raw_language)
if raw_language and not language:
errors["defaultLanguage"] = t("settings.language_unsupported")
if errors:
return Response(
{"detail": next(iter(errors.values())), "errors": errors}, status=400
)
fields = ["public_host", "public_scheme", "previous_public_host", "updated_at"]
fields = [
"public_host",
"public_scheme",
"previous_public_host",
"default_language",
"updated_at",
]
if host != row.public_host:
# Прежний адрес остаётся принятым: владелец меняет адрес заранее,
# сидя на старом, и не должен выпасть из установки в тот же миг.
row.previous_public_host = row.public_host
row.public_host = host
row.public_scheme = scheme
row.default_language = language or DEFAULT_LANGUAGE
email = body.get("email")
if isinstance(email, dict):
@@ -106,7 +123,7 @@ class InstanceAddressView(APIView):
row.email_port = int(email.get("port", row.email_port))
except (TypeError, ValueError):
return Response(
{"detail": "Порт — число", "errors": {"emailPort": "Порт — число"}},
{"detail": t("settings.port_is_number"), "errors": {"emailPort": t("settings.port_is_number")}},
status=400,
)
# Пустой пароль означает «оставить прежний»: наружу он не отдаётся.
@@ -131,7 +148,7 @@ class InstanceAddressView(APIView):
row.turn_ttl_seconds = max(60, int(turn.get("ttlSeconds", row.turn_ttl_seconds)))
except (TypeError, ValueError):
return Response(
{"detail": "Время жизни — число секунд",
{"detail": t("settings.ttl_is_seconds"),
"errors": {"turnTtlSeconds": "Число секунд"}},
status=400,
)
@@ -163,7 +180,7 @@ class InstanceEmailCheckView(APIView):
connection = email_connection()
if connection is None:
return Response(
{"detail": "Сначала укажите сервер исходящей почты"}, status=400
{"detail": t("settings.smtp_required_first")}, status=400
)
recipient = str(request.data.get("email", "")).strip() or request.user.email
try:
@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-09-09 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0034_encrypted_column_widths'),
]
operations = [
migrations.AddField(
model_name='humanuser',
name='ui_language',
field=models.CharField(blank=True, default='', max_length=5),
),
migrations.AddField(
model_name='instancesettings',
name='default_language',
field=models.CharField(blank=True, default='ru', max_length=5),
),
migrations.AddField(
model_name='organization',
name='language',
field=models.CharField(blank=True, default='', max_length=5),
),
]
+11
View File
@@ -78,6 +78,12 @@ class HumanUser(AbstractUser):
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
ui_accent = models.CharField(max_length=9, blank=True)
# Язык интерфейса — тоже глобальная настройка пользователя, а не
# membership: человек читает на одном языке во всех организациях, куда
# входит. Пустая строка — «как в организации»: это не английский и не
# русский, а отсутствие личного выбора, и она переживает смену языка
# организации, тогда как записанный при регистрации код — нет.
ui_language = models.CharField(max_length=5, blank=True, default="")
# Фото сотрудника (дизайн-базлайн v2): видно коллегам в сайдбаре, подписи
# сообщений, выборе ответственного. Загружается в профиле.
avatar = models.FileField(upload_to=user_avatar_upload_path, storage=user_storage, max_length=512, blank=True, default="")
@@ -115,6 +121,11 @@ class Organization(models.Model):
)
timezone = models.CharField(max_length=64, default="Europe/Moscow")
currency = models.CharField(max_length=3, default="RUB")
# Язык рабочего места по умолчанию: на нём организация открывается всем,
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
# это такой же региональный параметр организации, и в «Настройках» они
# лежат в одном разделе.
language = models.CharField(max_length=5, blank=True, default="")
logo = models.FileField(
upload_to=organization_logo_upload_path,
max_length=512,
@@ -2,6 +2,8 @@ import re
from django.core.exceptions import ValidationError
from chatballs.i18n import t
# Единые правила сложности пароля, зеркалят клиентскую проверку internal-ui.
_LETTER = re.compile(r"[A-Za-zА-Яа-я]")
_DIGIT = re.compile(r"\d")
@@ -13,11 +15,11 @@ class PasswordComplexityValidator:
def validate(self, password: str, user: object = None) -> None:
if not _LETTER.search(password):
raise ValidationError("Пароль должен содержать букву.", code="password_no_letter")
raise ValidationError(t("identity.password_needs_letter"), code="password_no_letter")
if not _DIGIT.search(password):
raise ValidationError("Пароль должен содержать цифру.", code="password_no_digit")
raise ValidationError(t("identity.password_needs_digit"), code="password_no_digit")
if not _SPECIAL.search(password):
raise ValidationError("Пароль должен содержать спецсимвол.", code="password_no_special")
raise ValidationError(t("identity.password_needs_special"), code="password_no_special")
def get_help_text(self) -> str:
return "Пароль должен содержать букву, цифру и спецсимвол."
+2 -1
View File
@@ -22,6 +22,7 @@ from django.db import connections, transaction
from django.utils.text import slugify
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.instance_settings import remember_public_host
from chatballs.identity.models import (
@@ -76,7 +77,7 @@ def _clean(data: SetupInput) -> SetupInput:
errors: dict[str, str] = {}
organization_name = data.organization_name.strip()
if not organization_name:
errors["organizationName"] = "Укажите название организации"
errors["organizationName"] = t("admin.organization_name_required")
elif len(organization_name) > ORGANIZATION_NAME_MAX_LENGTH:
errors["organizationName"] = "Название длиннее 255 символов"
full_name = " ".join(data.full_name.split())
@@ -8,6 +8,7 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.auth.common import _user_payload
from chatballs.identity.setup import (
SetupAlreadyCompleted,
@@ -16,7 +17,10 @@ from chatballs.identity.setup import (
instance_needs_setup,
)
SETUP_CLOSED = {"detail": "Первый запуск уже выполнен: организация создана"}
# Функция, а не константа: язык запроса решается на каждый запрос заново.
def setup_closed() -> dict[str, str]:
return {"detail": t("identity.setup_already_done")}
def _validation_response(error: ValidationError) -> Response:
@@ -54,7 +58,7 @@ class SetupView(APIView):
def post(self, request: Request) -> Response:
body = request.data if isinstance(request.data, dict) else {}
if not instance_needs_setup():
return Response(SETUP_CLOSED, status=409)
return Response(setup_closed(), status=409)
try:
result = complete_setup(
SetupInput(
@@ -68,7 +72,7 @@ class SetupView(APIView):
public_scheme=request.scheme,
)
except SetupAlreadyCompleted:
return Response(SETUP_CLOSED, status=409)
return Response(setup_closed(), status=409)
except ValidationError as error:
return _validation_response(error)
owner = result.owner
@@ -61,6 +61,8 @@ class AdministrationApiTests(TestCase):
"name": "Example",
"timezone": "Europe/Moscow",
"currency": "RUB",
# Пустой язык — «как в установке»: организация своего не выбрала.
"language": "",
"logoUrl": None,
# Подпись «Сохранено …» у кнопки (кадр N1): пока правок не было — null.
"updatedAt": None,
+39
View File
@@ -325,6 +325,45 @@ class AuthEndpointTests(TestCase):
)
self.assertEqual(rejected.status_code, 400)
def test_profile_language_saves_personal_choice(self) -> None:
"""Язык — личная настройка; пустое значение возвращает к организации."""
self.client.login(username="owner@example.com", password="temporary-password")
response = self.client.post(
"/api/v1/auth/profile/language/",
data=json.dumps({"language": "en"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["user"]
self.assertEqual(payload["uiLanguage"], "en")
# Ответ уже на новом языке: интерфейс перерисуется по нему сразу.
self.assertEqual(payload["language"], "en")
owner = HumanUser.objects.get(email="owner@example.com")
self.assertEqual(owner.ui_language, "en")
self.assertTrue(
AuditEvent.objects.filter(action="identity.profile_language_changed").exists()
)
cleared = self.client.post(
"/api/v1/auth/profile/language/",
data=json.dumps({"language": ""}),
content_type="application/json",
)
self.assertEqual(cleared.status_code, 200)
# Пустая строка — не язык, а снятие личного выбора: человек снова едет
# за организацией.
self.assertEqual(cleared.json()["user"]["uiLanguage"], "")
rejected = self.client.post(
"/api/v1/auth/profile/language/",
data=json.dumps({"language": "de"}),
content_type="application/json",
)
self.assertEqual(rejected.status_code, 400)
def test_profile_password_changes_current_user_password(self) -> None:
self.client.login(username="owner@example.com", password="temporary-password")
@@ -11,6 +11,7 @@ from rest_framework.response import Response
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.features import supports_calls
from chatballs.integrations.models import Integration, IntegrationKind
@@ -50,7 +51,7 @@ class CommunicationSettingsView(APIView):
organization = request.tenant_context.organization
items = request.data.get("items")
if not isinstance(items, list):
return Response({"detail": "Ожидается список items"}, status=400)
return Response({"detail": t("settings.items_list_expected")}, status=400)
by_id = {i.id: i for i in _entry_points(organization)}
changed: list[dict[str, object]] = []
for raw in items:
@@ -58,7 +59,7 @@ class CommunicationSettingsView(APIView):
continue
integration = by_id.get(raw.get("id"))
if integration is None:
return Response({"detail": "Точка входа не найдена"}, status=404)
return Response({"detail": t("settings.entry_point_not_found")}, status=404)
fields: list[str] = []
for key, attr in (("voiceMessages", "voice_messages_enabled"), ("audioCalls", "audio_calls_enabled"), ("videoCalls", "video_calls_enabled")):
if key in raw:
+2 -1
View File
@@ -18,6 +18,7 @@ import ssl
import urllib.parse
import urllib.request
from chatballs.i18n import t
from chatballs.integrations.outbound import OutboundUrlRejected
SOCKS_SCHEMES = ("socks5", "socks5h")
@@ -158,7 +159,7 @@ def _import_socks():
return socks
except ImportError as error:
raise ValueError("Для SOCKS-прокси нужна зависимость PySocks (pip install PySocks)") from error
raise ValueError(t("settings.socks_needs_pysocks")) from error
class _SocksHTTPConnection(http.client.HTTPConnection):
+4 -3
View File
@@ -5,6 +5,7 @@ from rest_framework.response import Response
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.models import Integration
from chatballs.integrations.selectors import (
@@ -115,7 +116,7 @@ class IntegrationDetailView(APIView):
data=_input(request.data, current=integration),
)
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
return Response({"detail": t("settings.integration_not_found")}, status=404)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
_audit(request, "integrations.integration_updated", integration)
@@ -125,7 +126,7 @@ class IntegrationDetailView(APIView):
try:
integration = self._get(request, integration_id)
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
return Response({"detail": t("settings.integration_not_found")}, status=404)
_audit(request, "integrations.integration_deleted", integration)
delete_integration(context=request.tenant_context, integration=integration)
return Response(status=204)
@@ -141,7 +142,7 @@ class IntegrationTestView(APIView):
context=request.tenant_context, integration_id=integration_id
)
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
return Response({"detail": t("settings.integration_not_found")}, status=404)
integration = test_integration(
context=request.tenant_context, integration=integration
)
@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-09-09 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0009_alter_notification_type'),
]
operations = [
migrations.AddField(
model_name='notification',
name='body_key',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='notification',
name='text_params',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='notification',
name='title_key',
field=models.CharField(blank=True, default='', max_length=64),
),
]
@@ -33,8 +33,16 @@ class Notification(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="notifications")
type = models.CharField(max_length=32, choices=NotificationType.choices)
level = models.CharField(max_length=16, choices=NotificationLevel.choices, default=NotificationLevel.INFO)
# Текст на языке того, кто писал код: остаётся и для записей, сделанных
# до появления ключей, и для доставки в мессенджер, где рендерить нечем.
title = models.CharField(max_length=255)
body = models.TextField(blank=True)
# Ключи каталога и их параметры. Уведомление адресовано аудитории
# («операторам»), а не одному человеку, и разные операторы могут читать на
# разных языках — поэтому фраза собирается при показе, а не при записи.
title_key = models.CharField(max_length=64, blank=True, default="")
body_key = models.CharField(max_length=64, blank=True, default="")
text_params = models.JSONField(default=dict, blank=True)
# Диплинк по клику.
target_route = models.CharField(max_length=64, blank=True)
target_id = models.CharField(max_length=64, blank=True)
@@ -1,13 +1,27 @@
from chatballs.i18n import t
from chatballs.notifications.models import Notification
def _text(key: str, stored: str, params: dict) -> str:
"""Фраза на языке запроса, если у записи есть ключ каталога.
Уведомление адресовано аудитории, а не одному человеку: два оператора
одной организации могут читать на разных языках, и записать готовую фразу
в момент события нельзя. Записи, сделанные до появления ключей, приходят
сохранённым текстом — переписывать историю задним числом не за что.
"""
return t(key, **params) if key else stored
def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]:
params = notification.text_params or {}
return {
"id": notification.id,
"type": notification.type,
"level": notification.level,
"title": notification.title,
"body": notification.body,
"title": _text(notification.title_key, notification.title, params),
"body": _text(notification.body_key, notification.body, params),
"targetRoute": notification.target_route,
"targetId": notification.target_id,
"createdAt": notification.created_at.isoformat(),
@@ -31,6 +31,9 @@ def notify(
audience: str,
title: str,
body: str = "",
title_key: str = "",
body_key: str = "",
text_params: dict | None = None,
target_id: object = "",
recipient_user=None,
source_type: str = "",
@@ -51,6 +54,9 @@ def notify(
audience=audience,
title=title,
body=body,
title_key=title_key,
body_key=body_key,
text_params=text_params or {},
level=level or meta.get("level", NotificationLevel.INFO),
target_route=target_route,
target_id=str(target_id) if target_id else "",
@@ -3,6 +3,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.notifications.binding import deep_link, issue_binding_code, notifier_integrations
from chatballs.notifications.models import MessengerBinding, NotificationRead, NotificationType
from chatballs.notifications.selectors import unread_for, visible_for
@@ -69,7 +70,7 @@ class MessengerBindingDetailView(APIView):
"""Выдать одноразовый код привязки и deep-link на бота."""
integration = self._integration(request, integration_id)
if integration is None:
return Response({"detail": "Бот уведомлений не найден"}, status=404)
return Response({"detail": t("profile.notification_bot_not_found")}, status=404)
binding_code = issue_binding_code(
context=request.tenant_context, integration=integration
)
@@ -86,7 +87,7 @@ class MessengerBindingDetailView(APIView):
"""Обновить типы уведомлений, доставляемые в мессенджер."""
binding = MessengerBinding.objects.filter(user=request.user, integration_id=integration_id).first()
if binding is None:
return Response({"detail": "Привязка не найдена"}, status=404)
return Response({"detail": t("profile.link_not_found")}, status=404)
types = request.data.get("pushTypes")
if not isinstance(types, list):
return Response({"detail": "pushTypes must be a list"}, status=400)
@@ -6,6 +6,8 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from chatballs.i18n import t
HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
@@ -16,10 +18,10 @@ def normalize_domain(value: str) -> str:
def validate_domain(value: str) -> str:
domain = normalize_domain(value)
if not domain or len(domain) > 253:
raise ValidationError("Некорректное доменное имя")
raise ValidationError(t("portals.invalid_domain"))
labels = domain.split(".")
if len(labels) < 2 or any(not HOST_LABEL_RE.fullmatch(label) for label in labels):
raise ValidationError("Некорректное доменное имя")
raise ValidationError(t("portals.invalid_domain"))
return domain
@@ -1,6 +1,7 @@
from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.support_portals.article_import import import_articles
from chatballs.support_portals.portal_views import PortalBaseView
@@ -10,7 +11,7 @@ class ArticleImportView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
articles = request.data.get("articles")
if not isinstance(articles, list) or not articles:
return Response({"detail": "articles must be a non-empty list"}, status=400)
@@ -8,6 +8,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.content_markdown import normalize_file_links
from chatballs.support_portals.models import (
PortalArticle,
@@ -193,7 +194,7 @@ def _category(portal: SupportPortal, category_id) -> PortalCategory:
try:
return portal.categories.get(id=int(category_id))
except (PortalCategory.DoesNotExist, TypeError, ValueError) as error:
raise ValidationError({"categoryId": "Раздел не найден"}) from error
raise ValidationError({"categoryId": t("portals.section_not_found")}) from error
def ensure_portal_editable(portal: SupportPortal) -> None:
@@ -4,6 +4,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.api.pagination import page_payload, paginate
from chatballs.i18n import t
from chatballs.support_portals.api import validation_response
from chatballs.support_portals.content_services import (
add_article_file,
@@ -47,7 +48,7 @@ class CategoryListView(PortalBaseView):
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
counts = category_article_counts(portal)
return Response(
{
@@ -61,7 +62,7 @@ class CategoryListView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
category = create_category(
context=request.tenant_context,
@@ -71,7 +72,7 @@ class CategoryListView(PortalBaseView):
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректная категория"}, status=400)
return Response({"detail": t("portals.invalid_category")}, status=400)
return Response({"category": category_payload(category)}, status=201)
@@ -87,9 +88,9 @@ class CategoryDetailView(PortalBaseView):
) -> Response:
portal, category = self._category(request, portal_id, category_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
if category is None:
return Response({"detail": "Раздел не найден"}, status=404)
return Response({"detail": t("portals.section_not_found")}, status=404)
try:
category = update_category(
portal=portal, category=category, data=request.data
@@ -97,7 +98,7 @@ class CategoryDetailView(PortalBaseView):
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректный раздел"}, status=400)
return Response({"detail": t("portals.invalid_section")}, status=400)
return Response({"category": category_payload(category)})
def delete(
@@ -105,9 +106,9 @@ class CategoryDetailView(PortalBaseView):
) -> Response:
portal, category = self._category(request, portal_id, category_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
if category is None:
return Response({"detail": "Раздел не найден"}, status=404)
return Response({"detail": t("portals.section_not_found")}, status=404)
try:
delete_category(portal=portal, category=category)
except ValidationError as error:
@@ -119,7 +120,7 @@ class ArticleListView(PortalBaseView):
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
"""Страница библиотеки: категория, язык, статус и поиск — на сервере."""
page = paginate(
portal_articles_queryset(portal, request.query_params),
@@ -131,7 +132,7 @@ class ArticleListView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
article = create_article(
context=request.tenant_context,
@@ -141,7 +142,7 @@ class ArticleListView(PortalBaseView):
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректная статья"}, status=400)
return Response({"detail": t("portals.invalid_article")}, status=400)
article = _article(portal, article.id)
return Response(
{"article": article_payload(article, revisions=True, files=True)},
@@ -153,25 +154,25 @@ class ArticleDetailView(PortalBaseView):
def get(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response({"article": article_payload(article, revisions=True, files=True)})
def patch(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
try:
article = update_article(article=article, data=request.data)
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректные данные статьи"}, status=400)
return Response({"detail": t("portals.invalid_article_data")}, status=400)
article = _article(portal, article.id)
return Response({"article": article_payload(article, revisions=True, files=True)})
@@ -180,10 +181,10 @@ class ArticleRevisionListView(PortalBaseView):
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
try:
revision = add_revision(
context=request.tenant_context,
@@ -200,10 +201,10 @@ class ArticlePublishView(PortalBaseView):
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
try:
article = publish_revision(
article=article,
@@ -212,7 +213,7 @@ class ArticlePublishView(PortalBaseView):
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректная версия"}, status=400)
return Response({"detail": t("portals.invalid_revision")}, status=400)
article = _article(portal, article.id)
return Response({"article": article_payload(article, revisions=True, files=True)})
@@ -221,10 +222,10 @@ class ArticleArchiveView(PortalBaseView):
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
article = archive_article(article)
return Response({"article": article_payload(article, revisions=True, files=True)})
@@ -237,10 +238,10 @@ class ArticleFileListView(PortalBaseView):
def get(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response(
{"items": [article_file_payload(item) for item in article.files.all()]}
)
@@ -248,14 +249,14 @@ class ArticleFileListView(PortalBaseView):
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
upload = request.FILES.get("file")
if upload is None:
return Response(
{"detail": "Нужен файл в поле file (multipart/form-data)"}, status=400
{"detail": t("portals.file_field_required")}, status=400
)
try:
article_file = add_article_file(
@@ -275,13 +276,13 @@ class ArticleFileDetailView(PortalBaseView):
) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
article_file = article.files.filter(id=file_id).first()
if article_file is None:
return Response({"detail": "Файл не найден"}, status=404)
return Response({"detail": t("portals.file_not_found")}, status=404)
try:
delete_article_file(
context=request.tenant_context, article_file=article_file
@@ -6,6 +6,7 @@ from rest_framework.views import APIView
from chatballs.api.pagination import page_payload, paginate
from chatballs.api.permissions import HasCapability
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
from chatballs.support_portals.api import validation_response
@@ -173,14 +174,14 @@ class PortalDetailView(PortalBaseView):
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
counts = portal_content_counts(request.tenant_context).get(portal.id, {})
return Response({"portal": portal_payload(portal, counts=counts)})
def patch(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
update_portal(
context=request.tenant_context,
@@ -197,7 +198,7 @@ class PortalStatusView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
portal = set_portal_status(
context=request.tenant_context,
@@ -224,7 +225,7 @@ class PortalWidgetOptionsView(PortalBaseView):
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
widgets = (
WebChatWidget.objects.select_related(
"integration",
@@ -248,7 +249,7 @@ class PortalDomainView(PortalBaseView):
def put(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
portal = set_custom_domain(
portal, str(request.data.get("customDomain", ""))
@@ -263,7 +264,7 @@ class PortalDomainVerifyView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
portal = verify_custom_domain(portal)
except ValidationError as error:
@@ -4,6 +4,7 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.support_portals.content_services import record_feedback
from chatballs.support_portals.models import SupportPortal
@@ -55,7 +56,7 @@ class PublicPortalDetailView(PublicPortalView):
def get(self, request: Request) -> Response:
resolved = self.resolve(request)
if resolved is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
with tenant_atomic(context):
counts = category_article_counts(portal, published_only=True)
@@ -76,7 +77,7 @@ class PublicArticleListView(PublicPortalView):
def get(self, request: Request) -> Response:
resolved = self.resolve(request)
if resolved is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
locale = str(request.query_params.get("locale", portal.default_locale))
with tenant_atomic(context):
@@ -90,7 +91,7 @@ class PublicArticleListView(PublicPortalView):
limit = min(max(int(request.query_params.get("limit", 50)), 1), 100)
offset = max(int(request.query_params.get("offset", 0)), 0)
except (TypeError, ValueError):
return Response({"detail": "Некорректная пагинация"}, status=400)
return Response({"detail": t("portals.invalid_pagination")}, status=400)
total = articles.count()
page = articles[offset : offset + limit]
return Response(
@@ -113,7 +114,7 @@ class PublicArticleDetailView(PublicPortalView):
def get(self, request: Request, article_slug: str) -> Response:
resolved = self.resolve(request)
if resolved is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
locale = str(request.query_params.get("locale", portal.default_locale))
with tenant_atomic(context):
@@ -121,7 +122,7 @@ class PublicArticleDetailView(PublicPortalView):
slug=article_slug
).first()
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response({"article": public_article_payload(article)})
@@ -132,10 +133,10 @@ class PublicArticleFeedbackView(PublicPortalView):
def post(self, request: Request, article_slug: str) -> Response:
resolved = self.resolve(request)
if resolved is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
helpful = request.data.get("helpful")
if not isinstance(helpful, bool):
return Response({"detail": "helpful должен быть boolean"}, status=400)
return Response({"detail": t("portals.helpful_must_be_boolean")}, status=400)
context, portal = resolved
locale = str(request.query_params.get("locale", portal.default_locale))
with tenant_atomic(context):
@@ -143,6 +144,6 @@ class PublicArticleFeedbackView(PublicPortalView):
slug=article_slug
).first()
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
record_feedback(article, helpful)
return Response({"ok": True}, status=201)
@@ -16,6 +16,7 @@ from django.core.files import File
from django.utils import timezone
from chatballs.events.handlers import register
from chatballs.i18n import t
from chatballs.tenancy import storage_settings as ss
from chatballs.tenancy.context import TenantContext
@@ -38,7 +39,7 @@ def _local_keys(root: Path) -> list[str]:
def migrate_local_to_s3(row: ss.StorageSettings, *, batch_progress: int = 25) -> int:
config = ss.config_from_settings(row)
if not config.s3_configured:
raise ValueError("S3 не настроено")
raise ValueError(t("settings.s3_not_configured"))
s3 = ss.build_s3_storage(config)
root = Path(settings.MEDIA_ROOT)
keys = _local_keys(root)
@@ -17,6 +17,7 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from chatballs.i18n import t
from chatballs.identity.crypto import EncryptedCharField
@@ -158,7 +159,7 @@ def build_s3_storage(config: StorageConfig, *, probe: bool = False):
from botocore.config import Config
from storages.backends.s3 import S3Storage
except ImportError as error: # pragma: no cover - deployment guard
raise ValidationError("django-storages[s3] не установлен") from error
raise ValidationError(t("settings.storages_missing")) from error
client_config = (
Config(connect_timeout=5, read_timeout=10, retries={"max_attempts": 1})
if probe
@@ -18,6 +18,7 @@ from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.tenancy import storage_settings as ss
@@ -91,7 +92,7 @@ class StorageSettingsView(APIView):
body = request.data
backend = str(body.get("backend") or row.backend).upper()
if backend not in ss.StorageBackend.values:
return Response({"detail": "Неизвестный тип хранилища", "errors": {"backend": "LOCAL или S3"}}, status=400)
return Response({"detail": t("settings.unknown_storage_kind"), "errors": {"backend": "LOCAL или S3"}}, status=400)
config = _apply_fields(row, body)
if backend == ss.StorageBackend.S3:
try:
@@ -150,9 +151,9 @@ class StorageMigrateView(APIView):
def post(self, request: Request) -> Response:
row = ss.StorageSettings.load()
if row.backend != ss.StorageBackend.S3 or not row.s3_configured:
return Response({"detail": "Сначала включите и сохраните S3-хранилище"}, status=400)
return Response({"detail": t("settings.enable_s3_first")}, status=400)
if row.migration_status == ss.StorageMigrationStatus.RUNNING:
return Response({"detail": "Перенос уже идёт"}, status=409)
return Response({"detail": t("settings.migration_running")}, status=409)
row.migration_status = ss.StorageMigrationStatus.RUNNING
row.migration_total = 0
row.migration_done = 0
+13 -2
View File
@@ -18,6 +18,9 @@ from chatballs.conversations.models import (
MessageKind,
)
from chatballs.conversations.transports.base import InboundMessage
from chatballs.i18n import t
from chatballs.i18n.languages import resolve_language
from chatballs.identity.instance_settings import default_language
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.tenancy.context import TenantContext
@@ -92,12 +95,20 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
for sib in Integration.objects.filter(channel=channel).exclude(id=integration.id):
username = sib.config.get("bot_username")
if sib.provider == IntegrationProvider.TELEGRAM and username:
fallback.append({"label": "Написать в Telegram", "url": f"https://t.me/{username}"})
fallback.append({"label": t("webchat.write_in_telegram"), "url": f"https://t.me/{username}"})
elif sib.provider == IntegrationProvider.MAX and username:
fallback.append({"label": "Написать в MAX", "url": ""})
fallback.append({"label": t("webchat.write_in_max"), "url": ""})
return {
"available": True,
"widgetKey": widget.public_key,
# Язык обвязки виджета — язык организации: на нём отвечают и агент, и
# оператор, и английская кнопка «Send» вокруг русских ответов выглядела
# бы ошибкой. Если организация языка не выбрала, берётся язык
# установки; пустое значение оставляет решение браузеру посетителя.
"language": resolve_language(
organization_language=context.organization.language,
instance_language=default_language(),
),
# Что разрешено в этой точке входа: виджет прячет микрофон при запрете.
"features": features_payload(integration),
"title": cfg.get("title") or channel.name,
+20 -19
View File
@@ -14,6 +14,7 @@ from chatballs.conversations.attachment_views import (
)
from chatballs.conversations.models import Message, MessageKind
from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.integrations.features import voice_messages_allowed
from chatballs.integrations.models import IntegrationStatus
@@ -136,15 +137,15 @@ class WebchatSessionView(_Public):
channel_code = str(request.data.get("channel", ""))
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
if context is None or widget is None:
return Response({"detail": "Виджет недоступен"}, status=404)
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
if (
not widget.integration.channel.allow_anonymous_sessions
or not services.origin_allowed(widget, host_origin(request))
):
return Response({"detail": "Виджет недоступен"}, status=404)
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
result = services.issue_session(context=context, widget=widget)
if result is None:
return Response({"detail": "Виджет недоступен"}, status=404)
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
return Response(result, status=201)
@@ -155,17 +156,17 @@ class WebchatMessagesView(_PublicSession):
def post(self, request: Request) -> Response:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
upload = request.FILES.get("audio")
if upload is not None:
# Голосовое из виджета (дизайн-базлайн v2, кадр H).
if not voice_messages_allowed(session.connection):
return Response({"detail": "Голосовые отключены"}, status=400)
return Response({"detail": t("webchat.voice_off")}, status=400)
if upload.size > MAX_VOICE_BYTES:
return Response({"detail": "Аудио больше 10 МБ"}, status=400)
return Response({"detail": t("conversations.audio_too_large")}, status=400)
content_type = (upload.content_type or "audio/webm").split(";")[0]
if content_type not in ALLOWED_AUDIO_TYPES:
return Response({"detail": "Неподдерживаемый формат аудио"}, status=400)
return Response({"detail": t("conversations.audio_format_unsupported")}, status=400)
try:
duration = max(0, int(request.data.get("duration", 0)))
except (TypeError, ValueError):
@@ -192,14 +193,14 @@ class WebchatMessagesView(_PublicSession):
return Response({"ok": True}, status=201)
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": "Пустое сообщение"}, status=400)
return Response({"detail": t("ai.empty_message")}, status=400)
services.post_message(session, text[:4000])
return Response({"ok": True}, status=201)
def get(self, request: Request) -> Response:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
try:
since = int(request.GET.get("since", "0") or 0)
except ValueError:
@@ -211,7 +212,7 @@ class WebchatMessageAudioView(_PublicSession):
def get(self, request: Request, message_id: int) -> Response | FileResponse:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
message = (
Message.objects.filter(
id=message_id,
@@ -223,7 +224,7 @@ class WebchatMessageAudioView(_PublicSession):
.first()
)
if message is None:
return Response({"detail": "Сообщение не найдено"}, status=404)
return Response({"detail": t("conversations.message_not_found")}, status=404)
response = FileResponse(
message.audio.open("rb"),
content_type=message.audio_content_type or "audio/ogg",
@@ -236,7 +237,7 @@ class WebchatMessageAttachmentView(_PublicSession):
def get(self, request: Request, message_id: int) -> Response | FileResponse:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
message = (
Message.objects.filter(
id=message_id,
@@ -248,7 +249,7 @@ class WebchatMessageAttachmentView(_PublicSession):
.first()
)
if message is None:
return Response({"detail": "Сообщение не найдено"}, status=404)
return Response({"detail": t("conversations.message_not_found")}, status=404)
response = attachment_response(message, inline="inline" in request.GET)
response["Cache-Control"] = "private, max-age=3600"
return response
@@ -258,10 +259,10 @@ class WebchatContactView(_PublicSession):
def post(self, request: Request) -> Response:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
phone = services.normalize_phone(str(request.data.get("phone", "")))
if not phone:
return Response({"detail": "Некорректный номер телефона"}, status=400)
return Response({"detail": t("webchat.invalid_phone")}, status=400)
services.post_contact(session, phone)
return Response({"ok": True}, status=201)
@@ -274,11 +275,11 @@ class WebchatCallOpenView(_PublicSession):
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
try:
resolved = open_call_for_identity(identity=session.identity)
except CallTokenError:
return Response({"detail": "Активное приглашение не найдено"}, status=404)
return Response({"detail": t("webchat.no_active_invite")}, status=404)
response = Response(
{
"call": public_invite_payload(resolved.invite.call_session, resolved.invite.expires_at),
@@ -297,11 +298,11 @@ class WebchatCallDeclineView(_PublicSession):
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
try:
decline_call_for_identity(identity=session.identity)
except CallTokenError:
return Response({"detail": "Активное приглашение не найдено"}, status=404)
return Response({"detail": t("webchat.no_active_invite")}, status=404)
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
return Response({"ok": True})
@@ -5,6 +5,8 @@ from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
from chatballs.i18n.languages import DEFAULT_LANGUAGE
from chatballs.i18n.languages import LANGUAGES as CHATBALLS_LANGUAGES
from chatballs_backend.settings_database import build_databases
from chatballs_backend.settings_env import env_bool, env_list, env_secret
from chatballs_backend.settings_storage import build_storage_settings
@@ -85,6 +87,9 @@ MIDDLEWARE = [
"chatballs.identity.middleware.SessionActivityMiddleware",
"chatballs.events.middleware.CorrelationIdMiddleware",
"chatballs.tenancy.middleware.TenantContextMiddleware",
# Последней в цепочке — ближе всех к вьюхе: язык организации известен
# только после того, как TenantContextMiddleware её определил.
"chatballs.i18n.middleware.LanguageMiddleware",
]
TEMPLATES = [
@@ -141,7 +146,13 @@ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "chatballs.identity.password_validation.PasswordComplexityValidator"},
]
LANGUAGE_CODE = "ru-ru"
# Язык — настройка продукта, а не окружения: его выбирает владелец в
# «Настройках» и каждый сотрудник в профиле, а LANGUAGE_CODE остаётся только
# последним рубежом для кода, который выполняется вне запроса (команды,
# фоновые задачи). Список языков общий с каталогом chatballs.i18n, чтобы
# добавление языка не требовало править настройки в двух местах.
LANGUAGE_CODE = DEFAULT_LANGUAGE
LANGUAGES = CHATBALLS_LANGUAGES
TIME_ZONE = "Europe/Moscow"
USE_I18N = True
USE_TZ = True
+18 -4
View File
@@ -1,10 +1,13 @@
import { ConfigProvider } from "antd";
import enUS from "antd/locale/en_US";
import ruRU from "antd/locale/ru_RU";
import { useCallback, useEffect, useMemo, useState } from "react";
import { buildTheme } from "@chatballs/ui";
import { applyAppearance, DEFAULT_ACCENT, resolvedDark } from "./shared/appearance";
import { acceptServerLanguage, language } from "./i18n";
import { api, setActiveOrganization } from "./api/client";
import { fetchAgentDirectory } from "./features/agents/model";
import { canAccess, defaultRoute, isManager } from "./auth/access";
@@ -30,6 +33,10 @@ export function App() {
() => buildTheme(resolvedDark(appearanceTheme), appearanceAccent),
[appearanceTheme, appearanceAccent],
);
// Свои строки antd — «Нет данных», подписи пагинации, календарь — берёт из
// собственных каталогов, и без locale остаётся английским посреди русского
// экрана. Язык здесь уже окончательный: смена приходит перезагрузкой.
const antdLocale = language() === "en" ? enUS : ruRU;
const [user, setUser] = useState<SessionUser | null>(null);
const [organizationPublicId, setOrganizationPublicId] = useState<string | null>(initialRoute.organizationPublicId);
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
@@ -43,6 +50,10 @@ export function App() {
const { navigate } = navigation;
const useIdentity = useCallback((nextIdentity: AuthenticatedUser, requestedId: string | null) => {
// Язык приходит уже разрешённым сервером. Если он разошёлся с тем, на
// котором отрисован экран, страница перезагрузится — иначе половина
// подписей осталась бы на прежнем языке (см. комментарий в src/i18n).
acceptServerLanguage(nextIdentity.language);
const activeUser = activateOrganization(nextIdentity, requestedId);
setIdentity(nextIdentity);
setUser(activeUser);
@@ -72,8 +83,11 @@ export function App() {
}, [user]);
useEffect(() => {
api<{ authenticated: boolean; user?: AuthenticatedUser }>("/api/v1/auth/session/")
api<{ authenticated: boolean; user?: AuthenticatedUser; language?: string }>("/api/v1/auth/session/")
.then((payload) => {
// Язык установки приходит и для гостя: экран входа принадлежит коробке,
// и на английской машине он должен открываться на языке установки.
if (!payload.authenticated) acceptServerLanguage(payload.language);
if (payload.authenticated && payload.user) {
const activeUser = useIdentity(payload.user, initialRoute.organizationPublicId);
if (activeUser && !initialRoute.organizationPublicId) {
@@ -133,16 +147,16 @@ export function App() {
if (resetting) {
return (
<ConfigProvider theme={antdTheme}>
<ConfigProvider theme={antdTheme} locale={antdLocale}>
<AuthResetPassword onDone={() => { setResetting(false); window.history.replaceState({}, "", pathFromRoute("chat")); }} />
</ConfigProvider>
);
}
if (sessionLoading) return <ConfigProvider theme={antdTheme}><LoadingScreen /></ConfigProvider>;
if (sessionLoading) return <ConfigProvider theme={antdTheme} locale={antdLocale}><LoadingScreen /></ConfigProvider>;
return (
<ConfigProvider theme={antdTheme}>
<ConfigProvider theme={antdTheme} locale={antdLocale}>
{totpChallenge ? (
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
) : !identity ? (
+6 -4
View File
@@ -1,3 +1,5 @@
import { t } from "../i18n";
const configuredApiBase = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
let activeOrganizationPublicId: string | null = null;
@@ -37,7 +39,7 @@ export class ApiError<TPayload extends { detail?: string } = ApiErrorPayload> ex
readonly payload: TPayload;
constructor(status: number, payload: TPayload) {
super(payload.detail ?? "Ошибка запроса");
super(payload.detail ?? t("common.request_failed"));
this.name = "ApiError";
this.status = status;
this.payload = payload;
@@ -101,7 +103,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
headers,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ detail: "Ошибка запроса" })) as ApiErrorPayload;
const payload = await response.json().catch(() => ({ detail: t("common.request_failed") })) as ApiErrorPayload;
throw new ApiError(response.status, payload);
}
if (response.status === 204 || response.status === 205) return undefined as T;
@@ -123,7 +125,7 @@ export function apiUpload<T>(path: string, form: FormData, onProgress?: (percent
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
};
}
request.onerror = () => reject(new ApiError(0, { detail: "Ошибка сети" }));
request.onerror = () => reject(new ApiError(0, { detail: t("shared.network_error") }));
request.onload = () => {
let payload: unknown = null;
try {
@@ -135,7 +137,7 @@ export function apiUpload<T>(path: string, form: FormData, onProgress?: (percent
resolve(payload as T);
return;
}
reject(new ApiError(request.status, (payload ?? { detail: "Ошибка запроса" }) as ApiErrorPayload));
reject(new ApiError(request.status, (payload ?? { detail: t("common.request_failed") }) as ApiErrorPayload));
};
request.send(form);
});
+4 -2
View File
@@ -12,8 +12,10 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
totpEnabled: false,
totpLastUsedAt: null,
deliveryMode: "CLOUD",
uiTheme: "SYSTEM",
uiAccent: "",
uiTheme: "SYSTEM",
uiAccent: "",
uiLanguage: "",
language: "ru",
memberships: [],
organizationPublicId: "org-1",
organization: "org",
@@ -9,6 +9,7 @@ import {
type AuditPeriod,
type AuditQuery,
} from "./model";
import { t } from "../../i18n";
// Фильтры журнала: поиск · период · раздел · сотрудник · результат · «Сбросить».
// Те же контролы, что у списков Сотрудников и Контактов — SearchInput,
@@ -27,20 +28,20 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
const [actorOpen, setActorOpen] = useState(false);
const periodLabel = AUDIT_PERIODS.find(([value]) => value === query.period)?.[1] ?? "";
const categoryLabel = categories.find((item) => item.value === query.category)?.label ?? "Все";
const actorLabel = actors.find((item) => item.value === query.actor)?.label ?? "Все";
const categoryLabel = categories.find((item) => item.value === query.category)?.label ?? t("common.all");
const actorLabel = actors.find((item) => item.value === query.actor)?.label ?? t("common.all");
return (
<div className="audit-filters">
<SearchInput
className="audit-search"
hotkey="/"
placeholder="Поиск по действию, объекту или сотруднику…"
placeholder={t("admin.search_by_action_object_or")}
value={query.q}
onChange={(value) => setQuery({ q: value })}
/>
<FilterDropdown
caption="Период"
caption={t("admin.period")}
label={periodLabel}
open={periodOpen}
options={AUDIT_PERIODS.map(([value, label]) => ({ value, label }))}
@@ -49,27 +50,27 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
onSelect={(value) => setQuery({ period: value as AuditPeriod })}
/>
<FilterDropdown
caption="Раздел"
caption={t("admin.section")}
label={categoryLabel}
open={categoryOpen}
options={[{ value: "", label: "Все" }, ...categories]}
options={[{ value: "", label: t("common.all") }, ...categories]}
selected={query.category ? [query.category] : []}
onOpenChange={setCategoryOpen}
onSelect={(value) => setQuery({ category: value })}
/>
{actors.length > 1 && (
<FilterDropdown
caption="Сотрудник"
caption={t("common.operator")}
label={actorLabel}
open={actorOpen}
options={[{ value: "", label: "Все" }, ...actors]}
options={[{ value: "", label: t("common.all") }, ...actors]}
selected={query.actor ? [query.actor] : []}
onOpenChange={setActorOpen}
onSelect={(value) => setQuery({ actor: value })}
/>
)}
<Segmented
items={[["", "Все"], ["SUCCESS", "Выполнено"], ["DENIED", "Отклонено"], ["FAILED", "Ошибка"]]}
items={[["", t("common.all")], ["SUCCESS", t("admin.succeeded")], ["DENIED", t("admin.rejected")], ["FAILED", t("common.error")]]}
value={query.result}
setValue={(value) => setQuery({ result: value })}
/>
@@ -78,9 +79,7 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
className={`audit-reset ${auditQueryIsDirty(query) ? "is-dirty" : ""}`}
type="button"
onClick={reset}
>
Сбросить
</button>
>{t("common.reset")}</button>
</div>
);
}
@@ -1,11 +1,11 @@
import { EmptyState, LoadingState, PageHeader } from "../../shared/ui";
import { Pagination } from "../../shared/Pagination";
import { Button } from "../../shared/ui-controls";
import { pluralRu } from "../../shared/utils";
import { AuditFilters } from "./AuditFilters";
import { AuditTable } from "./AuditTable";
import { auditQueryIsDirty } from "./model";
import { useAudit } from "./useAudit";
import { t, tn } from "../../i18n";
// «Аудит действий» — отдельный экран из субменю «Настроек». Журнал отдавался
// последними 50 событиями без фильтров и без страниц: дальше пятидесятого
@@ -18,10 +18,10 @@ export function AuditPage() {
const header = (
<PageHeader
title="Аудит"
title={t("common.audit")}
text={payload
? `Журнал действий в организации · ${pluralRu(payload.total, ["событие", "события", "событий"])}`
: "Журнал действий в организации"}
? t("admin.audit_log_with_total", { total: tn("plural.events", payload.total) })
: t("admin.log_actions_organization")}
/>
);
@@ -30,7 +30,7 @@ export function AuditPage() {
<div className="audit-page">
{header}
<EmptyState title={error} />
<Button variant="secondary" onClick={() => void audit.reload()}>Повторить</Button>
<Button variant="secondary" onClick={() => void audit.reload()}>{t("common.try_again")}</Button>
</div>
);
}
@@ -56,13 +56,13 @@ export function AuditPage() {
таблица моргает пустотой на каждый ввод в поиске. */}
<div className={`audit-results ${loading ? "is-loading" : ""}`}>
{payload.items.length === 0
? <EmptyState title={dirty ? "Под фильтры ничего не подошло" : "Событий пока нет"} />
? <EmptyState title={dirty ? t("admin.nothing_matched_filters") : t("admin.no_events_yet")} />
: <AuditTable events={payload.items} />}
</div>
{payload.pageCount > 1 && (
<Pagination
className="audit-pagination"
note={`Показано ${payload.items.length} из ${payload.total} · по ${payload.pageSize} на странице`}
note={t("common.shown_of_page", { shown: payload.items.length, total: payload.total, size: payload.pageSize })}
page={payload.page}
pageCount={payload.pageCount}
onPage={(page) => setQuery({ page })}
@@ -3,6 +3,7 @@ import { Fragment, useState } from "react";
import { Icon } from "../../shared/icons";
import { shortDate, shortDateYear } from "../../shared/utils";
import type { AuditEvent } from "./model";
import { t } from "../../i18n";
// Журнал действий. Три вещи, без которых он был нечитаем:
// * день отбивается заголовком — иначе сотни строк идут сплошняком;
@@ -26,16 +27,16 @@ function dayLabel(value: string): string {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (dayKey(value) === dayKey(today.toISOString())) return "Сегодня";
if (dayKey(value) === dayKey(yesterday.toISOString())) return "Вчера";
if (dayKey(value) === dayKey(today.toISOString())) return t("common.today");
if (dayKey(value) === dayKey(yesterday.toISOString())) return t("admin.yesterday");
return date.getFullYear() === today.getFullYear() ? shortDate(date) : shortDateYear(date);
}
function detailLines(event: AuditEvent): Array<[string, string]> {
const lines: Array<[string, string]> = [];
if (event.actorEmail) lines.push(["Сотрудник", `${event.actor} · ${event.actorEmail}`]);
lines.push(["Код действия", event.action]);
if (event.objectType) lines.push(["Объект", `${event.objectType}${event.objectId ? ` · ${event.objectId}` : ""}`]);
if (event.actorEmail) lines.push([t("common.operator"), `${event.actor} · ${event.actorEmail}`]);
lines.push([t("admin.action_code"), event.action]);
if (event.objectType) lines.push([t("common.object"), `${event.objectType}${event.objectId ? ` · ${event.objectId}` : ""}`]);
if (event.sourceIp) lines.push(["IP", event.sourceIp]);
if (event.correlationId) lines.push(["Correlation id", event.correlationId]);
Object.entries(event.details ?? {}).forEach(([key, value]) => {
@@ -53,11 +54,11 @@ export function AuditTable({ events }: { events: AuditEvent[] }) {
<table className="baseline-table audit-table">
<thead>
<tr>
<th className="audit-col-time">Время</th>
<th className="audit-col-actor">Сотрудник</th>
<th>Действие</th>
<th className="audit-col-object">Объект</th>
<th className="audit-col-result">Результат</th>
<th className="audit-col-time">{t("admin.time")}</th>
<th className="audit-col-actor">{t("common.operator")}</th>
<th>{t("common.action")}</th>
<th className="audit-col-object">{t("common.object")}</th>
<th className="audit-col-result">{t("admin.result")}</th>
<th className="audit-col-toggle" />
</tr>
</thead>
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Button } from "../../shared/ui-controls";
import { t } from "../../i18n";
export function OrganizationLogoField({
logoUrl,
@@ -25,12 +26,12 @@ export function OrganizationLogoField({
<div className="administration-logo-field">
<div className="administration-logo-preview">
{logoUrl
? <img src={logoUrl} alt={`Логотип ${name}`} />
? <img src={logoUrl} alt={t("admin.organization_logo_alt", { name })} />
: <span>{name.trim().slice(0, 2).toUpperCase() || "CR"}</span>}
</div>
<div className="administration-logo-copy">
<strong>Логотип</strong>
<span>PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах.</span>
<strong>{t("admin.logo")}</strong>
<span>{t("admin.png_jpeg_or_webp_up")}</span>
</div>
{!disabled && (
<div className="administration-logo-actions">
@@ -49,17 +50,13 @@ export function OrganizationLogoField({
variant="secondary"
disabled={saving}
onClick={() => input.current?.click()}
>
Загрузить
</Button>
>{t("common.upload")}</Button>
<button
className="administration-logo-remove"
type="button"
disabled={saving || !logoUrl}
onClick={() => setConfirmingRemoval(true)}
>
Убрать
</button>
>{t("common.remove")}</button>
</div>
)}
<DecisionDialog
@@ -67,13 +64,11 @@ export function OrganizationLogoField({
onClose={() => setConfirmingRemoval(false)}
tone="danger"
icon="trash"
title="Удалить логотип?"
description="Вместо него в меню снова будет показан знак Chatballs."
title={t("admin.remove_logo")}
description={t("admin.chatballs_mark_will_shown_menu")}
actions={(
<>
<Button variant="secondary" onClick={() => setConfirmingRemoval(false)}>
Отмена
</Button>
<Button variant="secondary" onClick={() => setConfirmingRemoval(false)}>{t("common.cancel")}</Button>
<Button
variant="danger-outline"
disabled={saving}
@@ -81,9 +76,7 @@ export function OrganizationLogoField({
setConfirmingRemoval(false);
onRemove();
}}
>
Удалить
</Button>
>{t("common.delete")}</Button>
</>
)}
/>
@@ -3,12 +3,14 @@ import type { FormEvent } from "react";
import { FormField, SelectField } from "../../shared/form-controls";
import { shortDateTime, timezoneLabel } from "../../shared/utils";
import { Button } from "../../shared/ui-controls";
import type { OrganizationLanguageOption } from "./api";
import type { OrganizationSettings } from "./model";
import { OrganizationLogoField } from "./OrganizationLogoField";
import { t } from "../../i18n";
function savedLabel(updatedAt: string | null): string {
if (!updatedAt) return "Ещё не сохранялось";
return `Сохранено ${shortDateTime(updatedAt)}`;
if (!updatedAt) return t("common.never_saved_yet");
return t("time.saved_at", { time: shortDateTime(updatedAt) });
}
export function OrganizationSettingsForm({
@@ -18,6 +20,7 @@ export function OrganizationSettingsForm({
message,
error,
timezones,
languages,
onChange,
onSave,
onUploadLogo,
@@ -29,6 +32,7 @@ export function OrganizationSettingsForm({
message: string;
error: string;
timezones: string[];
languages: OrganizationLanguageOption[];
onChange: (next: OrganizationSettings) => void;
onSave: () => void;
onUploadLogo: (file: File) => void;
@@ -52,7 +56,7 @@ export function OrganizationSettingsForm({
<div className="administration-fields">
<div className="administration-field-wide">
<FormField
label="Название организации"
label={t("admin.organization_name")}
value={organization.name}
disabled={!canManage}
onChange={canManage
@@ -61,18 +65,31 @@ export function OrganizationSettingsForm({
/>
</div>
<SelectField
label="Часовой пояс"
label={t("admin.time_zone")}
value={organization.timezone}
disabled={!canManage}
onChange={(timezone) => onChange({ ...organization, timezone })}
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label="Валюта"
label={t("settings.language")}
value={organization.language}
disabled={!canManage}
onChange={(language) => onChange({ ...organization, language })}
// Первый пункт — не язык, а отказ от выбора: организация без своего
// языка следует за установкой, и владелец видит это словами.
options={[
["", t("settings.language_as_installation")],
...languages.map((item): [string, string] => [item.code, item.label]),
]}
hint={t("settings.language_org_hint")}
/>
<SelectField
label={t("admin.currency")}
value={organization.currency}
disabled={!canManage}
onChange={(currency) => onChange({ ...organization, currency })}
options={[["RUB", "Российский рубль (RUB)"]]}
options={[["RUB", t("admin.russian_rouble_rub")]]}
/>
</div>
{error && <div className="administration-message error" role="alert">{error}</div>}
@@ -85,7 +102,7 @@ export function OrganizationSettingsForm({
variant="primary"
disabled={saving || !organization.name.trim()}
>
{saving ? "Сохранение" : "Сохранить"}
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
)}
@@ -3,11 +3,18 @@ import type { AuditPayload, AuditQuery, OrganizationSettings } from "./model";
const BASE = "/api/v1/company/administration/";
export type OrganizationLanguageOption = { code: string; label: string };
export async function loadOrganizationSettings(): Promise<{
organization: OrganizationSettings;
timezones: string[];
languages: OrganizationLanguageOption[];
}> {
return api<{ organization: OrganizationSettings; timezones: string[] }>(BASE);
return api<{
organization: OrganizationSettings;
timezones: string[];
languages: OrganizationLanguageOption[];
}>(BASE);
}
export async function saveOrganizationSettings(
@@ -19,6 +26,7 @@ export async function saveOrganizationSettings(
name: settings.name,
timezone: settings.timezone,
currency: settings.currency,
language: settings.language,
}),
});
return payload.organization;
@@ -1,5 +1,6 @@
import type { SessionUser } from "../../types";
import { hasCapability } from "../../auth/access";
import { t } from "../../i18n";
// Аудит живёт на своём экране (AuditPage) и своём хуке; useAdministration
// остался только под «Организацию» в Настройках.
@@ -9,6 +10,8 @@ export type OrganizationSettings = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке»: организация не обязана выбирать язык.
language: string;
logoUrl: string | null;
// Когда настройки сохраняли в последний раз — подпись у кнопки (кадр N1).
updatedAt: string | null;
@@ -65,11 +68,11 @@ export type AuditPayload = {
};
export const AUDIT_PERIODS: Array<[AuditPeriod, string]> = [
["today", "Сегодня"],
["7d", "7 дней"],
["30d", "30 дней"],
["90d", "90 дней"],
["all", "Всё время"],
["today", t("common.today")],
["7d", t("admin.7_days")],
["30d", t("admin.30_days")],
["90d", t("admin.90_days")],
["all", t("admin.all_time")],
];
export const EMPTY_AUDIT_QUERY: AuditQuery = {
@@ -8,7 +8,9 @@ import {
saveOrganizationSettings,
uploadOrganizationLogo,
} from "./api";
import type { OrganizationLanguageOption } from "./api";
import type { AdministrationSection, OrganizationSettings } from "./model";
import { t } from "../../i18n";
export function useAdministration({
section,
@@ -21,6 +23,7 @@ export function useAdministration({
}) {
const [organization, setOrganization] = useState<OrganizationSettings | null>(null);
const [timezones, setTimezones] = useState<string[]>([]);
const [languages, setLanguages] = useState<OrganizationLanguageOption[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
@@ -40,8 +43,9 @@ export function useAdministration({
const settings = await loadOrganizationSettings();
setOrganization(settings.organization);
setTimezones(settings.timezones);
setLanguages(settings.languages);
} catch (loadError) {
setError(loadError instanceof ApiError ? loadError.message : "Не удалось загрузить данные");
setError(loadError instanceof ApiError ? loadError.message : t("admin.could_not_load_data"));
} finally {
setLoading(false);
}
@@ -64,7 +68,7 @@ export function useAdministration({
setMessage(successMessage);
await refreshIdentity();
} catch (changeError) {
setError(changeError instanceof ApiError ? changeError.message : "Не удалось сохранить изменения");
setError(changeError instanceof ApiError ? changeError.message : t("admin.could_not_save_changes"));
} finally {
setSaving(false);
}
@@ -77,19 +81,20 @@ export function useAdministration({
organization,
saving,
timezones,
languages,
setOrganization,
reload: load,
save: () => organization && runChange(
() => saveOrganizationSettings(organization),
"Данные организации сохранены",
t("admin.organization_details_saved"),
),
uploadLogo: (file: File) => runChange(
() => uploadOrganizationLogo(file),
"Логотип обновлён",
t("admin.logo_updated"),
),
removeLogo: () => runChange(
removeOrganizationLogo,
"Логотип удалён",
t("admin.logo_removed"),
),
};
}
@@ -4,6 +4,7 @@ import { useDebounced } from "../../shared/useDebounced";
import { usePagedResource } from "../../shared/usePagedResource";
import { loadAudit } from "./api";
import { EMPTY_AUDIT_QUERY, type AuditPayload, type AuditQuery } from "./model";
import { t } from "../../i18n";
// Журнал аудита: фильтры и страница живут на сервере — журнал растёт без
// предела, и отдать его целиком нельзя. Страницей, отложенным поиском и
@@ -27,7 +28,7 @@ export function useAudit() {
(page: number) => loadAudit({ ...filters, page }),
[filters],
);
const journal = usePagedResource<AuditPayload>(load, filters, "Не удалось загрузить журнал");
const journal = usePagedResource<AuditPayload>(load, filters, t("admin.could_not_load_log"));
const setQuery = useCallback((patch: Partial<AuditQuery>) => {
// Страницу двигает подвал списка, фильтры — состояние запроса; смена
@@ -33,6 +33,7 @@ import {
type AgentConnection,
type AgentPatch,
} from "./model";
import { t } from "../../i18n";
// Карточка агента (дизайн-базлайн v2, «Агенты Baseline», кадры G3–G5): слева —
// что агент знает и как говорит (инструкции, знания), справа — как он работает
@@ -118,7 +119,7 @@ export function AgentDetailPage({
if (canManageConnections) void loadMessengers();
}, [canManageConnections, loadMessengers]);
if (missing) return <EmptyState title="Агент не найден" />;
if (missing) return <EmptyState title={t("ai.agent_not_found")} />;
if (failed) return <ErrorScreen retry={() => void reload()} />;
if (!card) return <LoadingState />;
@@ -132,7 +133,7 @@ export function AgentDetailPage({
} catch (caught) {
setFeedback({
kind: "error",
text: caught instanceof ApiError ? caught.payload.detail ?? "Не удалось сохранить" : "Не удалось сохранить",
text: caught instanceof ApiError ? caught.payload.detail ?? t("common.could_not_save") : t("common.could_not_save"),
});
return false;
} finally {
@@ -151,7 +152,7 @@ export function AgentDetailPage({
kind: "error",
text: caught instanceof ApiError && caught.payload.detail
? caught.payload.detail
: "Не удалось изменить статус AI",
: t("ai.could_not_change_ai_status"),
});
} finally {
setBusy(false);
@@ -169,8 +170,8 @@ export function AgentDetailPage({
kind: "warning",
text:
caught instanceof ApiError && caught.status === 409
? "Агента нельзя удалить: есть диалоги или подключения. Отвяжите подключения и закройте диалоги."
: "Не удалось удалить агента",
? t("ai.agent_cannot_deleted_has_conversations")
: t("ai.could_not_delete_agent"),
});
} finally {
setBusy(false);
@@ -184,7 +185,7 @@ export function AgentDetailPage({
setCard(saved.agent);
void loadMessengers();
} catch {
setFeedback({ kind: "error", text: "Не удалось отвязать подключение" });
setFeedback({ kind: "error", text: t("ai.could_not_detach_connection") });
} finally {
setBusy(false);
}
@@ -202,8 +203,8 @@ export function AgentDetailPage({
kind: "error",
text:
caught instanceof ApiError && caught.status === 409
? "Подключение уже привязано к другому агенту"
: "Не удалось привязать подключение",
? t("ai.connection_already_bound_another_agent")
: t("ai.could_not_bind_connection"),
});
} finally {
setBusy(false);
@@ -216,14 +217,14 @@ export function AgentDetailPage({
// Выключение агента и удаление живут в этом меню, а не отдельными кнопками
// под карточкой: на странице остаётся один переключатель AI.
const menuItems = [
{ key: "copy-code", label: <button type="button" onClick={() => { setMenuOpen(false); void navigator.clipboard?.writeText(card.code); }}><Icon name="copy" size={15} />Скопировать код</button> },
{ key: "copy-code", label: <button type="button" onClick={() => { setMenuOpen(false); void navigator.clipboard?.writeText(card.code); }}><Icon name="copy" size={15} />{t("ai.copy_snippet")}</button> },
{ type: "divider" as const },
{
key: "active",
disabled: busy,
label: (
<button type="button" onClick={() => { setMenuOpen(false); void apply({ isActive: !card.isActive }); }}>
<Icon name={card.isActive ? "xCircle" : "check"} size={15} />{card.isActive ? "Выключить агента" : "Включить агента"}
<Icon name={card.isActive ? "xCircle" : "check"} size={15} />{card.isActive ? t("ai.turn_agent_off") : t("ai.turn_agent")}
</button>
),
},
@@ -232,15 +233,14 @@ export function AgentDetailPage({
disabled: busy,
label: (
<button className="danger" type="button" onClick={() => { setMenuOpen(false); setDeleting(true); }}>
<Icon name="trash" size={15} />Удалить агента
</button>
<Icon name="trash" size={15} />{t("ai.delete_agent")}</button>
),
},
];
return (
<div className="agent-page">
<BackLink label="Агенты" onClick={openAgents} />
<BackLink label={t("common.agents")} onClick={openAgents} />
<header className="agent-head">
<span className="agent-head-tile" style={{ background: tile.background, color: tile.color }}>
@@ -252,22 +252,22 @@ export function AgentDetailPage({
<b className="agents-status" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
</div>
<p>
<span><i style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />{card.groupName ?? "Без группы"}</span>
<span><i style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />{card.groupName ?? t("common.no_group")}</span>
<i />
<code>{card.code}</code>
<i />
{openDialogsLine(card.counters.openConversations)}
<i />
создан {createdLabel(card.createdAt)}
{t("ai.created_on", { date: createdLabel(card.createdAt) })}
</p>
</div>
{canManage && (
<div className="agent-head-actions">
<Button variant="secondary" className="agent-toggle" icon={running ? "pause" : "bolt"} disabled={busy} onClick={() => void toggleAi()}>
{running ? "Остановить AI" : "Запустить AI"}
{running ? t("ai.stop_ai") : t("ai.start_ai")}
</Button>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown">
<button className="agent-head-menu" type="button" aria-label="Действия агента" title="Действия"><Icon name="more" size={17} strokeWidth={2} /></button>
<button className="agent-head-menu" type="button" aria-label={t("ai.agent_actions")} title={t("common.actions")}><Icon name="more" size={17} strokeWidth={2} /></button>
</Dropdown>
</div>
)}
@@ -277,11 +277,11 @@ export function AgentDetailPage({
<div className="agent-blocker">
<Icon name="alert" size={18} strokeWidth={2.2} />
<div>
<strong>AI не может отвечать: провайдер не выбран</strong>
<small>Добавьте ключ в «Настройки → AI-провайдер» и выберите провайдера в блоке «Модель». До этого диалоги агента ждут человека.</small>
<strong>{t("ai.ai_cannot_reply_no_provider")}</strong>
<small>{t("ai.add_key_under_settings_ai")}</small>
</div>
{canManage && (
<button type="button" onClick={openAiProvider}>Открыть настройки<Icon name="external" size={13} strokeWidth={2.2} /></button>
<button type="button" onClick={openAiProvider}>{t("ai.open_settings")}<Icon name="external" size={13} strokeWidth={2.2} /></button>
)}
</div>
)}
@@ -311,14 +311,14 @@ export function AgentDetailPage({
{deleting && (
<Modal
open
title="Удалить агента?"
okText="Удалить"
cancelText="Отмена"
title={t("ai.delete_agent_2")}
okText={t("common.delete")}
cancelText={t("common.cancel")}
okButtonProps={{ danger: true, disabled: busy }}
onOk={() => void removeAgent()}
onCancel={() => setDeleting(false)}
>
<p>Агент «{card.name}» будет удалён безвозвратно. Диалоги и подключения не дают удалить агента.</p>
<p>{t("ai.agent_will_be_deleted", { name: card.name })}</p>
</Modal>
)}
</div>
@@ -328,9 +328,9 @@ export function AgentDetailPage({
// --- Инструкции (кадры G3/G5) ---
const INSTRUCTION_FIELDS = [
{ key: "persona", label: "Кто он и что делает", hint: "персонализация", rows: 7, placeholder: "" },
{ key: "tone", label: "Как он должен говорить", hint: "тон", rows: 7, placeholder: "Например: спокойно и вежливо, на «вы»" },
{ key: "instructions", label: "Правила работы", hint: "инструкции", rows: 10, placeholder: "Например: не называй цены, направляй к консультанту" },
{ key: "persona", label: t("ai.who_what_does"), hint: t("ai.persona"), rows: 7, placeholder: "" },
{ key: "tone", label: t("ai.how_should_speak"), hint: t("ai.tone"), rows: 7, placeholder: t("ai.example_calmly_politely_formal_address") },
{ key: "instructions", label: t("ai.working_rules"), hint: t("ai.instructions"), rows: 10, placeholder: t("ai.example_do_not_quote_prices") },
] as const;
function InstructionsCard({ card, canManage, busy, apply }: {
@@ -348,8 +348,8 @@ function InstructionsCard({ card, canManage, busy, apply }: {
return (
<section className="agent-card">
<div className="agent-card-head">
<h3>Инструкции</h3>
<small>Системный промпт собирается из трёх частей в этом порядке</small>
<h3>{t("ai.instructions_2")}</h3>
<small>{t("ai.system_prompt_assembled_from_three")}</small>
</div>
<div className="agent-instructions">
{INSTRUCTION_FIELDS.map((field) => (
@@ -369,9 +369,9 @@ function InstructionsCard({ card, canManage, busy, apply }: {
</div>
{canManage && dirty && (
<div className="agent-dirty">
<small>Есть несохранённые изменения — применятся в runtime сразу</small>
<button type="button" disabled={busy} onClick={() => setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions })}>Отменить</button>
<button className="is-primary" type="button" disabled={busy} onClick={() => void apply(draft)}>Сохранить</button>
<small>{t("ai.there_unsaved_changes_they_take")}</small>
<button type="button" disabled={busy} onClick={() => setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions })}>{t("ai.undo")}</button>
<button className="is-primary" type="button" disabled={busy} onClick={() => void apply(draft)}>{t("common.save")}</button>
</div>
)}
</section>
@@ -398,15 +398,14 @@ function KnowledgeCard({ card, canManage, openKnowledge, reload }: {
return (
<section className="agent-card">
<div className="agent-card-head is-row">
<div><h3>Знания</h3><small>{knowledgeLine(card)}</small></div>
<div><h3>{t("ai.knowledge")}</h3><small>{knowledgeLine(card)}</small></div>
{canManage && (
<button className="agent-inline-button" type="button" onClick={() => setPicking(true)}>
<Icon name="plus" size={13} strokeWidth={2.2} />Выбрать
</button>
<Icon name="plus" size={13} strokeWidth={2.2} />{t("ai.select")}</button>
)}
</div>
{rows.length === 0 ? (
<p className="agent-knowledge-empty">Знания не прикреплены — агент отвечает только по инструкциям. Выберите статьи из библиотеки или портала поддержки.</p>
<p className="agent-knowledge-empty">{t("ai.no_knowledge_attached_so_agent")}</p>
) : (
<div className="agent-knowledge-list">
{rows.map((row) => (
@@ -418,7 +417,7 @@ function KnowledgeCard({ card, canManage, openKnowledge, reload }: {
{row.chip && <small className={`agent-chip is-${row.chipTone}`}>{row.chip}</small>}
<small className="agent-knowledge-meta">{row.meta}</small>
{canManage && (
<button className="agent-knowledge-remove" type="button" aria-label="Убрать" title="Убрать" onClick={() => void detach(row.kind, row.id)}>
<button className="agent-knowledge-remove" type="button" aria-label={t("common.remove")} title={t("common.remove")} onClick={() => void detach(row.kind, row.id)}>
<Icon name="close" size={12} strokeWidth={2.4} />
</button>
)}
@@ -460,17 +459,17 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
return (
<section className="agent-card is-side">
<h3>Назначение</h3>
<p>Группа определяет, кто из сотрудников видит диалоги агента; без группы — видны всем.</p>
<h3>{t("ai.assignment")}</h3>
<p>{t("ai.group_decides_which_operators_see")}</p>
<div className="agent-side-fields">
<label className="agent-field">
<span>Название</span>
<span>{t("common.title")}</span>
{canManage
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
: <span className="agent-field-static">{card.name}</span>}
</label>
<label className="agent-field is-select">
<span>Группа</span>
<span>{t("common.group")}</span>
<span className="agent-field-control">
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
{canManage ? (
@@ -479,11 +478,11 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">Без группы</option>
<option value="">{t("common.no_group")}</option>
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
</select>
) : (
<span className="agent-field-static">{card.groupName ?? "Без группы"}</span>
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
@@ -515,11 +514,11 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
return (
<section className="agent-card is-side">
<h3>Модель</h3>
<p>Ключ провайдера — в «Настройки → AI-провайдер». Модель выбирается из каталога провайдера.</p>
<h3>{t("common.model")}</h3>
<p>{t("ai.provider_key_lives_under_settings")}</p>
<div className="agent-side-fields">
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
<span>Провайдер</span>
<span>{t("ai.provider")}</span>
<span className="agent-field-control">
{canManage ? (
<select
@@ -527,24 +526,24 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">Не выбран</option>
<option value="">{t("ai.not_selected")}</option>
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
</select>
) : (
<span className="agent-field-static">{providerName || "Не выбран"}</span>
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<label className="agent-field is-model">
<span>Модель</span>
<span>{t("common.model")}</span>
<span className="agent-field-control">
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? "выберите провайдера" : card.model}</span>
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
<div className="agent-limit">
<span>Лимит в день</span>
<span>{t("ai.daily_limit")}</span>
<span>
{canManage
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
@@ -572,17 +571,17 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
return (
<section className="agent-card is-side">
<div className="agent-card-head is-tight">
<h3>Подключения</h3>
<h3>{t("common.connections")}</h3>
{canManage && (
<button className="link has-icon" type="button" onClick={openIntegrations}>Интеграции<Icon name="external" size={13} strokeWidth={2.2} /></button>
<button className="link has-icon" type="button" onClick={openIntegrations}>{t("common.integrations")}<Icon name="external" size={13} strokeWidth={2.2} /></button>
)}
</div>
<p>Через них клиенты попадают к агенту. Одно подключение — один агент.</p>
<p>{t("ai.customers_reach_agent_through_these")}</p>
{card.connections.map((connection) => (
<ConnectionRow
connection={connection}
status={connectionStatusMeta(connection.status)}
action={canManage ? { label: "Отвязать", run: () => unbind(connection.id) } : null}
action={canManage ? { label: t("ai.unbind"), run: () => unbind(connection.id) } : null}
busy={busy}
key={connection.id}
/>
@@ -590,9 +589,9 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
{canManage && available.map((integration) => (
<ConnectionRow
connection={{ id: integration.id, provider: integration.provider, name: integration.name, status: integration.status, botUsername: "", email: "", allowedOrigins: [], widgetPublicKey: "" }}
subtitle="Свободное подключение"
status={{ text: "Свободно", bg: "var(--n-9)", color: "var(--n-4)" }}
action={{ label: "Привязать", run: () => bind(integration.id) }}
subtitle={t("ai.free_connection")}
status={{ text: t("ai.free"), bg: "var(--n-9)", color: "var(--n-4)" }}
action={{ label: t("ai.bind"), run: () => bind(integration.id) }}
busy={busy}
key={`free-${integration.id}`}
/>
@@ -600,11 +599,11 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
{widget && (
<div className="agent-widget">
<div>
<small>Код вставки виджета</small>
<CopyButton className="agent-widget-copy" label="Копировать" value={webWidgetSnippet(widget.widgetPublicKey)} />
<small>{t("ai.widget_embed_snippet")}</small>
<CopyButton className="agent-widget-copy" label={t("common.copy")} value={webWidgetSnippet(widget.widgetPublicKey)} />
</div>
<code>{webWidgetSnippet(widget.widgetPublicKey)}</code>
<small>Вставьте перед &lt;/body&gt; на сайте</small>
<small>{t("ai.paste_before_lt_body_gt")}</small>
</div>
)}
</section>
@@ -8,6 +8,7 @@ import { shortDate } from "../../shared/utils";
import { fetchKnowledgeList } from "../ai/knowledge/api";
import { listPortalArticles, listSupportPortals } from "../support-portals/api";
import type { AgentCard } from "./model";
import { t } from "../../i18n";
// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): один список из
// материалов библиотеки и опубликованных статей порталов. Агент отвечает
@@ -56,7 +57,7 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
id: article.id,
title: article.publishedRevision?.title ?? article.slug,
icon: "globe" as const,
meta: `Портал · ${portal.name}`,
meta: t("ai.portal_named", { name: portal.name }),
disabled: false,
})));
setChoices([
@@ -66,7 +67,7 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
id: item.id,
title: item.title,
icon: "doc",
meta: item.isEnabled ? `обновлено ${shortDate(item.updatedAt)}` : "Выключено",
meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"),
disabled: false,
})),
...articles,
@@ -105,20 +106,20 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
});
onClose();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить выбор");
setError(caught instanceof Error ? caught.message : t("ai.could_not_save_selection"));
setSaving(false);
}
}
return (
<Modal className="agent-knowledge-modal" open width={560} title="Выбрать знания" onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">Агент отвечает только по выбранным материалам: знаниям библиотеки и опубликованным статьям порталов.</p>
<SearchInput className="agent-knowledge-search" placeholder="Поиск по названию" value={search} onChange={setSearch} />
<Modal className="agent-knowledge-modal" open width={560} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">{t("ai.agent_answers_only_from_selected")}</p>
<SearchInput className="agent-knowledge-search" placeholder={t("ai.search_by_title")} value={search} onChange={setSearch} />
{choices === null && !failed && <LoadingState />}
{failed && <div className="agent-form-error">Не удалось загрузить список материалов</div>}
{failed && <div className="agent-form-error">{t("ai.could_not_load_material_list")}</div>}
{choices !== null && (
<div className="agent-knowledge-choices">
{visible.length === 0 && <p className="agent-knowledge-nothing">Ничего не найдено</p>}
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
{visible.map((choice) => (
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => toggle(choice.key)} />
@@ -131,9 +132,9 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
)}
{error && <div className="agent-form-error">{error}</div>}
<div className="agent-create-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={choices === null || saving} onClick={() => void submit()}>
{saving ? "Сохранение…" : "Сохранить"}
{saving ? t("ai.saving") : t("common.save")}
</Button>
</div>
</Modal>
@@ -19,6 +19,7 @@ import {
setAgentAiActive,
type AgentCard,
} from "./model";
import { t } from "../../i18n";
// Список агентов (дизайн-базлайн v2, «Агенты Baseline», кадры G1, G2, S1):
// агент · группа · статус · модель · подключения · открытые · ⋯.
@@ -38,28 +39,28 @@ function CreateAgentModal({ groups, onClose, onCreated }: { groups: EmployeeGrou
const created = await createAgent({ name: name.trim(), groupId });
onCreated(created.agent.id);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось создать агента");
setError(caught instanceof Error ? caught.message : t("ai.could_not_create_agent"));
setSubmitting(false);
}
}
return (
<Modal className="agent-create-modal" open width={440} title="Создать агента" onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">Один шаг. Инструкции, модель, знания и подключения — потом, на карточке агента.</p>
<Modal className="agent-create-modal" open width={440} title={t("profile.create_agent")} onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">{t("ai.one_step_instructions_model_knowledge")}</p>
<div className="agent-create-fields">
<FormField label="Название" value={name} onChange={setName} placeholder="Например: Приёмная" />
<FormField label={t("common.title")} value={name} onChange={setName} placeholder={t("ai.example_front_desk")} />
<SelectField
label="Группа"
label={t("common.group")}
value={groupId === null ? "" : String(groupId)}
onChange={(value) => setGroupId(value ? Number(value) : null)}
options={[["", "Без группы — диалоги видны всем"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
options={[["", t("ai.without_group_conversations_visible_everyone")], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
/>
{error && <div className="agent-form-error">{error}</div>}
</div>
<div className="agent-create-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={!name.trim() || submitting} onClick={() => void submit()}>
{submitting ? "Создание…" : "Создать"}
{submitting ? t("ai.creating") : t("ai.create_2")}
</Button>
</div>
</Modal>
@@ -73,14 +74,14 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
const status = agentStatusMeta(card);
const open = card.counters.openConversations;
const menuItems = [
{ key: "open", label: <button type="button" onClick={() => { setMenuOpen(false); openAgent(card.id); }}><Icon name="external" size={15} />Открыть карточку</button> },
{ key: "open", label: <button type="button" onClick={() => { setMenuOpen(false); openAgent(card.id); }}><Icon name="external" size={15} />{t("admin.open_card")}</button> },
{ type: "divider" as const },
{
key: "toggle",
label: (
<button type="button" className={card.aiStatus === "ACTIVE" ? "warning" : ""} onClick={() => { setMenuOpen(false); onToggleAi(card); }}>
<Icon name={card.aiStatus === "ACTIVE" ? "pause" : "bolt"} size={15} />
{card.aiStatus === "ACTIVE" ? "Остановить AI" : "Запустить AI"}
{card.aiStatus === "ACTIVE" ? t("ai.stop_ai") : t("ai.start_ai")}
</button>
),
},
@@ -99,7 +100,7 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
</div>
<span className={`agents-row-group ${card.groupId === null ? "is-none" : ""}`}>
{card.groupId !== null && <i style={{ background: groupColorOf(card.groupId, card.groupColor) }} />}
{card.groupName ?? "Без группы"}
{card.groupName ?? t("common.no_group")}
</span>
<b className="agents-status" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
<span className="agents-row-model">{agentModelLabel(card)}</span>
@@ -118,7 +119,7 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
</div>
<div className="agents-row-open" style={{ color: open ? "var(--warning-text)" : "var(--n-5)" }}>{open || "—"}</div>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown">
<button className="agents-row-menu" type="button" aria-label="Действия агента" title="Действия" onClick={(event) => event.stopPropagation()}><Icon name="more" size={16} strokeWidth={2} /></button>
<button className="agents-row-menu" type="button" aria-label={t("ai.agent_actions")} title={t("common.actions")} onClick={(event) => event.stopPropagation()}><Icon name="more" size={16} strokeWidth={2} /></button>
</Dropdown>
</div>
);
@@ -138,7 +139,7 @@ export function AgentsPage({
(page: number) => fetchAgentsPage({ group: "all", query: "" }, page),
[],
);
const agents = usePagedResource(load, null, "Не удалось загрузить агентов");
const agents = usePagedResource(load, null, t("ai.could_not_load_agents"));
async function toggleAi(card: AgentCard) {
await setAgentAiActive(card.id, card.aiStatus !== "ACTIVE").catch(() => undefined);
@@ -149,30 +150,30 @@ export function AgentsPage({
<div className="agents-page">
<header className="agents-header">
<div>
<h2>Агенты</h2>
<p>Агент отвечает клиентам первым и собирает диалоги в группу</p>
<h2>{t("common.agents")}</h2>
<p>{t("ai.agent_answers_customers_first_collects")}</p>
</div>
<Button variant="primary" className="agents-create" icon="plus" iconSize={15} onClick={() => setCreating(true)}>Создать агента</Button>
<Button variant="primary" className="agents-create" icon="plus" iconSize={15} onClick={() => setCreating(true)}>{t("profile.create_agent")}</Button>
</header>
{agents.errorText && <div className="agents-error">{agents.errorText}</div>}
{agents.total === 0 ? (
<div className="agents-empty">
<span><Icon name="robot" size={24} strokeWidth={1.8} /></span>
<div>
<strong>Агент отвечает клиентам первым. Создайте первого</strong>
<p>Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.</p>
<strong>{t("ai.agent_answers_customers_first_create")}</strong>
<p>{t("ai.name_group_all_takes_start")}</p>
</div>
<Button variant="primary" className="agents-create is-wide" onClick={() => setCreating(true)}>Создать агента</Button>
<Button variant="primary" className="agents-create is-wide" onClick={() => setCreating(true)}>{t("profile.create_agent")}</Button>
</div>
) : (
<div className="agents-table">
<div className="agents-thead">
<span>Агент</span>
<span>Группа</span>
<span>Статус</span>
<span>Модель</span>
<span>Подключения</span>
<span className="is-right">Открытые</span>
<span>{t("common.agent")}</span>
<span>{t("common.group")}</span>
<span>{t("common.status")}</span>
<span>{t("common.model")}</span>
<span>{t("common.connections")}</span>
<span className="is-right">{t("ai.open")}</span>
<span />
</div>
{agents.items.map((card) => (
+19 -18
View File
@@ -5,8 +5,9 @@ import { api } from "../../api/client";
import type { PagedPayload } from "../../shared/usePagedResource";
import { agentColorOf } from "../conversations/model";
import { channelMap, providerKey } from "../../shared/providers";
import { pluralRu, shortDate } from "../../shared/utils";
import { shortDate } from "../../shared/utils";
import type { AgentKnowledgeRef, AgentPortalArticleRef } from "../ai/model";
import { t, tn } from "../../i18n";
export type AgentConnection = {
id: number;
@@ -131,9 +132,9 @@ export type AgentStatusTone = "active" | "paused" | "disabled";
// таблица ST макета: «AI отвечает» на --ai, «Без AI» на предупреждении,
// «Выключен» серым.
const STATUS_META: Record<AgentStatusTone, { text: string; bg: string; color: string }> = {
active: { text: "AI отвечает", bg: "color-mix(in srgb, var(--ai) 14%, var(--surface-card))", color: "var(--ai)" },
paused: { text: "Без AI", bg: "var(--warning-bg)", color: "var(--warning-text)" },
disabled: { text: "Выключен", bg: "var(--n-9)", color: "var(--n-4)" },
active: { text: t("conversations.ai_replying"), bg: "color-mix(in srgb, var(--ai) 14%, var(--surface-card))", color: "var(--ai)" },
paused: { text: t("ai.no_ai_2"), bg: "var(--warning-bg)", color: "var(--warning-text)" },
disabled: { text: t("ai.off"), bg: "var(--n-9)", color: "var(--n-4)" },
};
export function agentStatusTone(card: AgentCard): AgentStatusTone {
@@ -168,31 +169,31 @@ export function agentTint(provider: string): { color: string; bg: string; full:
export function connectionSubtitle(connection: AgentConnection): string {
const key = providerKey(connection.provider);
if (key === "TG") return connection.botUsername ? `Telegram · @${connection.botUsername}` : "Telegram";
if (key === "WEB") return connection.allowedOrigins[0] ?? "Web-виджет";
if (key === "WEB") return connection.allowedOrigins[0] ?? t("common.web_widget");
if (key === "EMAIL") return connection.email || "Email";
return key ? channelMap[key].full : connection.provider;
}
// Таблица CS макета: подключено · ошибка · не проверено.
const CONNECTION_STATUS_META: Record<string, { text: string; bg: string; color: string }> = {
OK: { text: "Подключено", bg: "var(--success-bg)", color: "var(--success-text)" },
ERROR: { text: "Ошибка", bg: "var(--error-bg)", color: "var(--error-text)" },
OK: { text: t("common.connected"), bg: "var(--success-bg)", color: "var(--success-text)" },
ERROR: { text: t("common.error"), bg: "var(--error-bg)", color: "var(--error-text)" },
};
export function connectionStatusMeta(status: string): { text: string; bg: string; color: string } {
return CONNECTION_STATUS_META[status] ?? { text: "Не проверено", bg: "var(--n-9)", color: "var(--n-4)" };
return CONNECTION_STATUS_META[status] ?? { text: t("ai.not_checked"), bg: "var(--n-9)", color: "var(--n-4)" };
}
/** «5 открытых диалогов» / «нет диалогов» — подзаголовок шапки карточки. */
export function openDialogsLine(count: number): string {
return count === 0 ? "нет диалогов" : pluralRu(count, ["открытый диалог", "открытых диалога", "открытых диалогов"]);
return count === 0 ? t("ai.no_conversations") : tn("plural.open_conversations", count);
}
/** «создан 12 мар 2026»; созданный сегодня — «сегодня» (кадр G5). */
export function createdLabel(value: string, now = new Date()): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
if (date.toDateString() === now.toDateString()) return "сегодня";
if (date.toDateString() === now.toDateString()) return t("ai.today");
return `${shortDate(date)} ${date.getFullYear()}`;
}
@@ -217,9 +218,9 @@ export function knowledgeRows(card: AgentCard): AgentKnowledgeRow[] {
id: item.id,
title: item.title,
icon: "doc",
chip: item.isEnabled ? "" : "Выключено",
chip: item.isEnabled ? "" : t("common.off"),
chipTone: "muted",
meta: item.updatedAt ? `обновлено ${shortDate(item.updatedAt)}` : "",
meta: item.updatedAt ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : "",
href: "",
})),
...card.portalArticles.map((article): AgentKnowledgeRow => ({
@@ -228,9 +229,9 @@ export function knowledgeRows(card: AgentCard): AgentKnowledgeRow[] {
id: article.id,
title: article.title,
icon: "globe",
chip: `Портал · ${article.portal.name}`,
chip: t("ai.portal_named", { name: article.portal.name }),
chipTone: "accent",
meta: "статья",
meta: t("ai.article_meta"),
href: article.publicUrl,
})),
];
@@ -241,12 +242,12 @@ export function knowledgeLine(card: AgentCard): string {
const knowledge = card.knowledge.length;
const articles = card.portalArticles.length;
const selected = knowledge + articles;
if (selected === 0) return "ничего не выбрано";
const head = `${selected} из ${card.knowledgeTotal}`;
if (selected === 0) return t("ai.nothing_selected");
const head = t("common.n_of_m", { selected, total: card.knowledgeTotal });
if (knowledge === 0 || articles === 0) return head;
const parts = [
pluralRu(knowledge, ["знание", "знания", "знаний"]),
pluralRu(articles, ["статья", "статьи", "статей"]),
tn("plural.knowledge", knowledge),
tn("plural.articles", articles),
];
return `${head} · ${parts.join(" + ")}`;
}
@@ -4,9 +4,9 @@ import { useState } from "react";
import { Icon } from "../../../shared/icons";
import { Segmented } from "../../../shared/ui";
import { Button } from "../../../shared/ui-controls";
import { pluralRu } from "../../../shared/utils";
import type { AgentRef } from "../../agents/model";
import type { KnowledgeItem } from "./types";
import { t, tn } from "../../../i18n";
// Прикрепление знаний к агенту (дизайн-базлайн v2, кадр KB3). Один агент за
// операцию: SPEC-CHATBALLS-0029 §6 требует атомарности и журналирования, поэтому
@@ -14,9 +14,8 @@ import type { KnowledgeItem } from "./types";
type Mode = "attach" | "detach";
const MODES: Array<[Mode, string]> = [["attach", "Прикрепить"], ["detach", "Открепить"]];
const MODES: Array<[Mode, string]> = [["attach", t("common.attach")], ["detach", t("common.detach")]];
const KNOWLEDGE_FORMS: [string, string, string] = ["знание", "знания", "знаний"];
type Skip = { item: KnowledgeItem; reason: string };
@@ -29,18 +28,18 @@ function skipsFor(items: KnowledgeItem[], agent: AgentRef | null, mode: Mode): S
items.forEach((item) => {
const attached = (item.agentIds ?? []).includes(agentId);
if (mode === "attach") {
if (attached) skips.push({ item, reason: "уже прикреплён к этому агенту" });
else if (!item.isEnabled) skips.push({ item, reason: "выключен в ответах" });
if (attached) skips.push({ item, reason: t("ai.already_attached_agent") });
else if (!item.isEnabled) skips.push({ item, reason: t("ai.switched_off_replies") });
} else if (!attached) {
skips.push({ item, reason: "не прикреплён к этому агенту" });
skips.push({ item, reason: t("ai.not_attached_agent") });
}
});
return skips;
}
function agentMeta(agent: AgentRef): string {
if (agent.aiStatus !== "ACTIVE") return "Без AI — знания не используются";
return `AI отвечает · ${pluralRu(agent.knowledgeCount, KNOWLEDGE_FORMS)} прикреплено`;
if (agent.aiStatus !== "ACTIVE") return t("ai.no_ai_knowledge_not_used");
return t("ai.ai_replies_attached", { count: tn("plural.knowledge", agent.knowledgeCount) });
}
export function KnowledgeAgentDialog({
@@ -67,10 +66,10 @@ export function KnowledgeAgentDialog({
<header className="knowledge-agent-dialog-head">
<div className="knowledge-agent-dialog-title">
<div>
<h3>{mode === "attach" ? "Прикрепить к агенту" : "Открепить от агента"}</h3>
<p>Выбрано {pluralRu(items.length, KNOWLEDGE_FORMS)}. Один агент за операцию — так видно, что именно изменилось.</p>
<h3>{mode === "attach" ? t("ai.attach_agent") : t("ai.detach_from_agent")}</h3>
<p>{t("ai.selected_one_agent_note", { count: tn("plural.knowledge", items.length) })}</p>
</div>
<button aria-label="Закрыть" className="knowledge-dialog-close" title="Закрыть" type="button" onClick={onCancel}>
<button aria-label={t("common.close")} className="knowledge-dialog-close" title={t("common.close")} type="button" onClick={onCancel}>
<Icon name="close" size={15} strokeWidth={2.2} />
</button>
</div>
@@ -78,7 +77,7 @@ export function KnowledgeAgentDialog({
</header>
<div className="knowledge-agent-dialog-body">
<span className="knowledge-agent-dialog-label">АГЕНТ</span>
<span className="knowledge-agent-dialog-label">{t("ai.agent")}</span>
<div className="knowledge-agent-picks">
{agents.map((item) => {
const picked = item.aiAgentId === agentId;
@@ -95,11 +94,11 @@ export function KnowledgeAgentDialog({
<strong>{item.name}</strong>
<small>{agentMeta(item)}</small>
</span>
<small className="knowledge-agent-tag">{picked ? "выбран" : noAi ? "без AI" : "доступен"}</small>
<small className="knowledge-agent-tag">{picked ? t("ai.selected") : noAi ? t("ai.no_ai") : t("ai.available")}</small>
</button>
);
})}
{agents.length === 0 && <p className="knowledge-agent-empty">Агентов пока нет — создайте агента, чтобы прикреплять к нему знания.</p>}
{agents.length === 0 && <p className="knowledge-agent-empty">{t("ai.there_no_agents_yet_create")}</p>}
</div>
</div>
@@ -107,24 +106,22 @@ export function KnowledgeAgentDialog({
<div className="knowledge-agent-warning">
<Icon name="alert" size={16} strokeWidth={2} />
<span>
<b>{skips.length} из {items.length} будут пропущены:</b>{" "}
{skips.map((skip) => `«${skip.item.title}» ${skip.reason}`).join(", ")}. Состав знаний
агента по ним не изменится.
<b>{t("ai.skips_summary", { skipped: skips.length, total: items.length })}</b>{" "}
{skips.map((skip) => `«${skip.item.title}» ${skip.reason}`).join(", ")}. {t("ai.skips_tail")}
</span>
</div>
)}
<footer className="knowledge-agent-dialog-foot">
<span>
Операция атомарна: при недоступном идентификаторе не меняется ничего. Требуется право <code>ai.manage</code>.
<span>{t("ai.operation_atomic_if_any_identifier")}<code>ai.manage</code>.
</span>
<Button variant="secondary" disabled={busy} onClick={onCancel}>Отмена</Button>
<Button variant="secondary" disabled={busy} onClick={onCancel}>{t("common.cancel")}</Button>
<Button
variant="primary"
disabled={busy || agentId === null || applied === 0}
onClick={() => agentId !== null && onSubmit(agentId, mode)}
>
{mode === "attach" ? "Прикрепить" : "Открепить"} {applied}
{mode === "attach" ? t("common.attach") : t("common.detach")} {applied}
</Button>
</footer>
</Modal>
@@ -1,5 +1,5 @@
import { Icon } from "../../../shared/icons";
import { pluralRu } from "../../../shared/utils";
import { t, tn } from "../../../i18n";
// Панель массовых операций (дизайн-базлайн v2, кадр KB2): тёмная плашка по
// центру снизу, пока живо выделение. Прикрепление к агенту — главное действие.
@@ -26,26 +26,21 @@ export function KnowledgeBulkBar({
return (
<div className="knowledge-bulk-bar">
<span className="knowledge-bulk-count">
<strong>Выбрано {count}</strong>
<small>{pluralRu(count, ["знание", "знания", "знаний"]).replace(`${count} `, "")}</small>
<strong>{t("common.selected_count", { count })}</strong>
<small>{tn("plural.knowledge", count).replace(`${count} `, "")}</small>
</span>
<button className="is-primary" disabled={busy} type="button" onClick={onAttach}>
<Icon name="robot" size={14} strokeWidth={1.9} />Прикрепить к агенту
</button>
<Icon name="robot" size={14} strokeWidth={1.9} />{t("ai.attach_agent")}</button>
<button disabled={busy} type="button" onClick={onMove}>
<Icon name="move" size={14} strokeWidth={1.9} />Переместить
</button>
<Icon name="move" size={14} strokeWidth={1.9} />{t("ai.move")}</button>
<button disabled={busy} type="button" onClick={onEnable}>
<Icon name="eye" size={14} strokeWidth={1.9} />Включить
</button>
<Icon name="eye" size={14} strokeWidth={1.9} />{t("ai.turn")}</button>
<button disabled={busy} type="button" onClick={onDisable}>
<Icon name="eyeOff" size={14} strokeWidth={1.9} />Выключить
</button>
<Icon name="eyeOff" size={14} strokeWidth={1.9} />{t("ai.turn_off")}</button>
<button className="is-danger" disabled={busy} type="button" onClick={onRemove}>
<Icon name="trash" size={14} strokeWidth={1.9} />Удалить
</button>
<Icon name="trash" size={14} strokeWidth={1.9} />{t("common.delete")}</button>
<i className="knowledge-bulk-divider" />
<button aria-label="Снять выделение" className="knowledge-bulk-close" title="Снять выделение" type="button" onClick={onClear}>
<button aria-label={t("ai.clear_selection")} className="knowledge-bulk-close" title={t("ai.clear_selection")} type="button" onClick={onClear}>
<Icon name="close" size={15} strokeWidth={2.2} />
</button>
</div>
@@ -5,7 +5,7 @@ import { DecisionDialog } from "../../../shared/DecisionDialog";
import { Icon } from "../../../shared/icons";
import { EmptyState, LoadingState } from "../../../shared/ui";
import { Button, CopyButton } from "../../../shared/ui-controls";
import { pluralRu, shortDateTime } from "../../../shared/utils";
import { shortDateTime } from "../../../shared/utils";
import type { RouteKey } from "../../../types";
import type { AgentRef } from "../../agents/model";
import { MarkdownContent } from "../../help-center/MarkdownContent";
@@ -27,6 +27,7 @@ import {
type KnowledgeItem,
} from "./model";
import { useKnowledgeCategories } from "./useKnowledgeCategories";
import { fmt, t, tn } from "../../../i18n";
// Карточка знания (дизайн-базлайн v2, кадр KB4): шапка как у портала —
// состояние в ответах, фрагменты и агенты чипами; тело — содержимое до 720px
@@ -87,9 +88,9 @@ export function KnowledgeCardPage({
}
}
if (!knowledgeId) return <div className="knowledge-card"><EmptyState title="Знание не выбрано" /></div>;
if (!knowledgeId) return <div className="knowledge-card"><EmptyState title={t("ai.no_knowledge_item_selected")} /></div>;
if (loading || catalog.loading) return <div className="knowledge-card"><LoadingState /></div>;
if (failed || catalog.error || !item) return <div className="knowledge-card"><EmptyState title="Не удалось загрузить знание" /></div>;
if (failed || catalog.error || !item) return <div className="knowledge-card"><EmptyState title={t("ai.could_not_load_knowledge_item")} /></div>;
const path = knowledgeCategoryPath(catalog.categories, item.category.id);
const crumbs = path ? path.split(" / ") : [item.category.name];
@@ -98,18 +99,18 @@ export function KnowledgeCardPage({
const characters = (item.content ?? "").length;
const menuItems = canManage ? [
{ key: "move", label: <button type="button" onClick={() => setMovingOpen(true)}><Icon name="move" size={15} strokeWidth={1.9} />Переместить в категорию</button> },
{ key: "answers", label: <button type="button" onClick={() => void run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }), "Не удалось изменить участие в ответах")}><Icon name={item.isEnabled ? "eyeOff" : "eye"} size={15} strokeWidth={1.9} />{item.isEnabled ? "Выключить в ответах" : "Включить в ответах"}</button> },
{ key: "reindex", label: <button type="button" onClick={() => void run(() => reindexKnowledge(item.id), "Не удалось переиндексировать знание")}><Icon name="undo" size={15} strokeWidth={1.9} />Переиндексировать</button> },
{ key: "move", label: <button type="button" onClick={() => setMovingOpen(true)}><Icon name="move" size={15} strokeWidth={1.9} />{t("ai.move_category")}</button> },
{ key: "answers", label: <button type="button" onClick={() => void run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }), t("ai.could_not_change_participation_replies"))}><Icon name={item.isEnabled ? "eyeOff" : "eye"} size={15} strokeWidth={1.9} />{item.isEnabled ? t("ai.switch_off_replies") : t("ai.switch_replies")}</button> },
{ key: "reindex", label: <button type="button" onClick={() => void run(() => reindexKnowledge(item.id), t("ai.could_not_reindex_knowledge_item"))}><Icon name="undo" size={15} strokeWidth={1.9} />{t("ai.reindex")}</button> },
{ key: "divider", type: "divider" as const },
{ key: "delete", label: <button className="danger" type="button" onClick={() => setDeleteOpen(true)}><Icon name="trash" size={15} strokeWidth={1.9} />Удалить знание</button> },
{ key: "delete", label: <button className="danger" type="button" onClick={() => setDeleteOpen(true)}><Icon name="trash" size={15} strokeWidth={1.9} />{t("ai.delete_knowledge_item")}</button> },
] : [];
return (
<section className="knowledge-card">
<div className="knowledge-card-head">
<nav className="knowledge-breadcrumbs">
<button className="link is-strong" type="button" onClick={() => setRoute("knowledge")}>База знаний</button>
<button className="link is-strong" type="button" onClick={() => setRoute("knowledge")}>{t("common.knowledge_base")}</button>
{crumbs.map((crumb, index) => (
<span key={`${crumb}-${index}`}>
<span className="knowledge-crumb-sep">/</span>
@@ -122,35 +123,31 @@ export function KnowledgeCardPage({
<div className="knowledge-card-title">
<h2>{item.title}</h2>
<span className={`knowledge-answer-pill${item.isEnabled ? " is-on" : ""}`}>
<i />{item.isEnabled ? "В ответах агента" : "Не участвует в ответах"}
<i />{item.isEnabled ? t("ai.agent_s_replies") : t("ai.not_used_replies")}
</span>
</div>
<div className="knowledge-card-chips">
<span className="knowledge-chip">
<Icon name="box" size={14} strokeWidth={1.9} />
{pluralRu(fragments, ["фрагмент", "фрагмента", "фрагментов"])} · пересобраны {shortDateTime(item.updatedAt)}
{t("ai.chunks_rebuilt_at", { chunks: tn("plural.chunks", fragments), date: shortDateTime(item.updatedAt) })}
</span>
<span className="knowledge-chip">
<Icon name="robot" size={14} strokeWidth={1.9} />
{pluralRu(attached.length, ["агент", "агента", "агентов"])}
{tn("plural.agents", attached.length)}
</span>
{item.updatedBy && <span className="knowledge-card-author">обновил {item.updatedBy} · {shortDateTime(item.updatedAt)}</span>}
{item.updatedBy && <span className="knowledge-card-author">{t("ai.updated_by_at", { name: item.updatedBy, date: shortDateTime(item.updatedAt) })}</span>}
</div>
</div>
<div className="knowledge-card-actions">
{canManage && (
<Button variant="secondary" className="knowledge-secondary-action" icon="robot" onClick={() => setAttachOpen(true)}>
Прикрепить к агенту
</Button>
<Button variant="secondary" className="knowledge-secondary-action" icon="robot" onClick={() => setAttachOpen(true)}>{t("ai.attach_agent")}</Button>
)}
{canManage && (
<Button variant="primary" className="knowledge-primary-action" icon="edit" onClick={() => openKnowledgeEditor(item.id)}>
Редактировать
</Button>
<Button variant="primary" className="knowledge-primary-action" icon="edit" onClick={() => openKnowledgeEditor(item.id)}>{t("common.edit_item")}</Button>
)}
{canManage && (
<Dropdown menu={{ items: menuItems }} overlayClassName="app-dropdown is-knowledge-menu" placement="bottomRight" trigger={["click"]}>
<button aria-label="Ещё" className="knowledge-more-button" title="Ещё" type="button">
<button aria-label={t("common.more")} className="knowledge-more-button" title={t("common.more")} type="button">
<Icon name="more" size={16} strokeWidth={2} />
</button>
</Dropdown>
@@ -172,23 +169,23 @@ export function KnowledgeCardPage({
<aside className="knowledge-rail">
<div className="knowledge-rail-block is-top">
<div>
<span className="knowledge-rail-label">КАТЕГОРИЯ</span>
<span className="knowledge-rail-label">{t("common.category")}</span>
<span className="knowledge-rail-category">
<Icon name="folder" size={13} strokeWidth={1.8} />{path || item.category.name}
</span>
</div>
<div className="knowledge-rail-toggle">
<span>
<strong>Участвует в ответах</strong>
<small>выключенное знание не попадает в retrieval</small>
<strong>{t("ai.used_replies")}</strong>
<small>{t("ai.switched_off_item_never_reaches")}</small>
</span>
<button
aria-label="Участвует в ответах"
aria-label={t("ai.used_replies")}
aria-pressed={item.isEnabled}
className={`knowledge-switch${item.isEnabled ? " is-on" : ""}`}
disabled={!canManage || busy}
type="button"
onClick={() => void run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }), "Не удалось изменить участие в ответах")}
onClick={() => void run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }), t("ai.could_not_change_participation_replies"))}
>
<i />
</button>
@@ -197,8 +194,8 @@ export function KnowledgeCardPage({
<div className="knowledge-rail-block">
<div className="knowledge-rail-head">
<span>ВЛОЖЕНИЯ</span>
<small>ссылки уходят в ответ агента</small>
<span>{t("ai.attachments")}</span>
<small>{t("ai.links_go_out_agent_s")}</small>
</div>
<div className="knowledge-rail-files">
{item.attachments.map((attachment) => (
@@ -211,14 +208,14 @@ export function KnowledgeCardPage({
<CopyButton className="knowledge-rail-file-copy" label="" value={attachment.url} />
</div>
))}
{item.attachments.length === 0 && <p className="knowledge-rail-empty">Вложений нет.</p>}
{item.attachments.length === 0 && <p className="knowledge-rail-empty">{t("ai.no_attachments")}</p>}
</div>
</div>
<div className="knowledge-rail-block">
<div className="knowledge-rail-head">
<span>АГЕНТЫ</span>
{canManage && <button type="button" onClick={() => setAttachOpen(true)}>Изменить</button>}
<span>{t("common.agents_2")}</span>
{canManage && <button type="button" onClick={() => setAttachOpen(true)}>{t("common.edit")}</button>}
</div>
<div className="knowledge-rail-agents">
{attached.map(({ agent, meta, answering }) => (
@@ -228,30 +225,29 @@ export function KnowledgeCardPage({
<strong>{agent.name}</strong>
<small>{meta}</small>
</span>
<small className={`knowledge-agent-state${answering ? " is-on" : ""}`}>{answering ? "активен" : "не в ответах"}</small>
<small className={`knowledge-agent-state${answering ? " is-on" : ""}`}>{answering ? t("ai.active") : t("ai.not_replies")}</small>
</div>
))}
{attached.length === 0 && <p className="knowledge-rail-empty">Знание не прикреплено ни к одному агенту — в ответах оно не используется.</p>}
{attached.length === 0 && <p className="knowledge-rail-empty">{t("ai.item_attached_no_agent_so")}</p>}
</div>
</div>
<div className="knowledge-rail-block">
<span className="knowledge-rail-label">ИНДЕКСАЦИЯ</span>
<span className="knowledge-rail-label">{t("ai.indexing")}</span>
<div className="knowledge-index-box">
<div className="knowledge-index-count">
<strong>{fragments}</strong>
<span>фрагментов · {characters.toLocaleString("ru-RU")} знаков</span>
<span>{t("ai.chunks_and_characters", { characters: fmt.number(characters) })}</span>
</div>
<p>Фрагменты пересобираются при сохранении. Пока идёт пересборка, агент отвечает по прежней версии.</p>
<p>{t("ai.chunks_rebuilt_save_while_rebuild")}</p>
{canManage && (
<button
className="knowledge-reindex-button"
disabled={busy}
type="button"
onClick={() => void run(() => reindexKnowledge(item.id), "Не удалось переиндексировать знание")}
onClick={() => void run(() => reindexKnowledge(item.id), t("ai.could_not_reindex_knowledge_item"))}
>
<Icon name="undo" size={13} strokeWidth={1.9} />Переиндексировать
</button>
<Icon name="undo" size={13} strokeWidth={1.9} />{t("ai.reindex")}</button>
)}
</div>
</div>
@@ -268,7 +264,7 @@ export function KnowledgeCardPage({
setAttachOpen(false);
void run(
() => linkKnowledgeToAgent({ agentId, action: mode, knowledgeIds: [item.id] }),
"Не удалось изменить знания агента",
t("ai.could_not_change_agent_s"),
);
}}
/>
@@ -287,7 +283,7 @@ export function KnowledgeCardPage({
await bulkMoveKnowledge({ knowledgeIds: [item.id], categoryId });
await catalog.reload();
},
"Не удалось переместить знание",
t("ai.could_not_move_knowledge_item"),
);
}}
/>
@@ -298,10 +294,10 @@ export function KnowledgeCardPage({
onClose={() => setDeleteOpen(false)}
tone="danger"
icon="trash"
title="Удалить знание?"
description={`«${item.title}» и его вложения будут удалены. У агентов, которым знание прикреплено, оно исчезнет из ответов.`}
title={t("ai.delete_knowledge_item_2")}
description={t("ai.item_and_files_deleted", { title: item.title })}
actions={<>
<Button variant="secondary" onClick={() => setDeleteOpen(false)}>Отмена</Button>
<Button variant="secondary" onClick={() => setDeleteOpen(false)}>{t("common.cancel")}</Button>
<Button
variant="danger-outline"
disabled={busy}
@@ -310,11 +306,9 @@ export function KnowledgeCardPage({
setBusy(true);
void deleteKnowledgeItem(item.id)
.then(() => setRoute("knowledge"))
.catch(() => { setError("Не удалось удалить знание"); setBusy(false); });
.catch(() => { setError(t("ai.could_not_delete_knowledge_item")); setBusy(false); });
}}
>
Удалить
</Button>
>{t("common.delete")}</Button>
</>}
/>
</section>
@@ -4,7 +4,6 @@ import { DecisionDialog } from "../../../shared/DecisionDialog";
import { Icon } from "../../../shared/icons";
import { LoadingState } from "../../../shared/ui";
import { Button } from "../../../shared/ui-controls";
import { pluralRu } from "../../../shared/utils";
import {
buildCategoryTree,
planCategoryDrop,
@@ -18,6 +17,7 @@ import {
updateKnowledgeCategory,
} from "./model";
import { useKnowledgeCategories } from "./useKnowledgeCategories";
import { t, tn } from "../../../i18n";
// Категории знаний (дизайн-базлайн v2, кадр KB7): отдельный экран, а не
// модалка — дерево строками с перетаскиванием, инлайн-переименованием и
@@ -31,17 +31,17 @@ function flatten(nodes: ManagedContentCategoryNode[], depth = 0): Row[] {
/** «18 знаний · 2 вложенные» — подпись строки категории. */
function countLabel(node: ManagedContentCategoryNode): string {
if (node.count === 0 && node.children.length === 0) return "пусто";
const knowledge = pluralRu(node.count, ["знание", "знания", "знаний"]);
if (node.count === 0 && node.children.length === 0) return t("ai.empty");
const knowledge = tn("plural.knowledge", node.count);
if (node.children.length === 0) return knowledge;
return `${knowledge} · ${pluralRu(node.children.length, ["вложенная", "вложенные", "вложенных"])}`;
return `${knowledge} · ${tn("plural.nested", node.children.length)}`;
}
/** Почему нельзя удалить: системная, есть вложенные, есть материалы. */
function deleteBlock(node: ManagedContentCategoryNode): string | null {
if (node.isSystem) return "системная категория";
if (node.children.length > 0) return "есть вложенные категории";
if (node.count > 0) return "категория не пуста";
if (node.isSystem) return t("ai.system_category");
if (node.children.length > 0) return t("ai.has_nested_categories");
if (node.count > 0) return t("ai.category_not_empty");
return null;
}
@@ -94,7 +94,7 @@ export function KnowledgeCategoriesPage({
cancel();
await catalog.reload();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось изменить категории");
setError(caught instanceof Error ? caught.message : t("shared.could_not_change_categories"));
} finally {
setBusy(false);
}
@@ -124,11 +124,11 @@ export function KnowledgeCategoriesPage({
<section className="knowledge-card">
<div className="knowledge-card-head is-plain">
<nav className="knowledge-breadcrumbs">
<button className="link is-strong" type="button" onClick={() => setRoute("knowledge")}>База знаний</button>
<span><span className="knowledge-crumb-sep">/</span><b>Категории</b></span>
<button className="link is-strong" type="button" onClick={() => setRoute("knowledge")}>{t("common.knowledge_base")}</button>
<span><span className="knowledge-crumb-sep">/</span><b>{t("common.categories")}</b></span>
</nav>
<h2>Категории</h2>
<p>Дерево до трёх уровней. Перетащите строку, чтобы сменить родителя или порядок.</p>
<h2>{t("common.categories")}</h2>
<p>{t("ai.tree_up_three_levels_deep")}</p>
</div>
<div className="knowledge-plain-body">
@@ -151,7 +151,7 @@ export function KnowledgeCategoriesPage({
drop(node.id, dropPosition(event, Boolean(node.isSystem)));
}}
>
<span className="knowledge-category-grip" title="Перетащить"><Icon name="grip" size={15} strokeWidth={2} /></span>
<span className="knowledge-category-grip" title={t("ai.drag")}><Icon name="grip" size={15} strokeWidth={2} /></span>
{editing ? (
<>
<input
@@ -176,23 +176,21 @@ export function KnowledgeCategoriesPage({
if (!name || name === node.name) cancel();
else void run(() => updateKnowledgeCategory(node.id, { name }));
}}
>
Готово
</button>
<button className="knowledge-category-cancel" type="button" onClick={cancel}>Отмена</button>
>{t("common.done")}</button>
<button className="knowledge-category-cancel" type="button" onClick={cancel}>{t("common.cancel")}</button>
</>
) : (
<>
<span className="knowledge-category-name">
<strong style={{ fontWeight: depth === 0 ? 600 : 500 }}>{node.name}</strong>
{node.isSystem && <small className="knowledge-category-tag">системная</small>}
{node.isSystem && <small className="knowledge-category-tag">{t("ai.system")}</small>}
</span>
<small className="knowledge-category-count">{countLabel(node)}</small>
{canManage && (
<span className="knowledge-category-actions">
<button
disabled={depth >= 2}
title={depth >= 2 ? "Глубже трёх уровней категории не вкладываются" : "Добавить вложенную"}
title={depth >= 2 ? t("ai.categories_do_not_nest_deeper") : t("ai.add_nested_one")}
type="button"
onClick={() => { setEditingId(null); setCreateParentId(node.id); setDraftName(""); }}
>
@@ -200,7 +198,7 @@ export function KnowledgeCategoriesPage({
</button>
<button
disabled={Boolean(node.isSystem)}
title={node.isSystem ? "Системную категорию нельзя переименовать" : "Переименовать"}
title={node.isSystem ? t("ai.system_category_cannot_renamed") : t("ai.rename")}
type="button"
onClick={() => { setCreateParentId(undefined); setEditingId(node.id); setDraftName(node.name); }}
>
@@ -208,7 +206,7 @@ export function KnowledgeCategoriesPage({
</button>
<button
className={`knowledge-category-delete${block ? " is-blocked" : ""}`}
title={block ? `Удалить нельзя: ${block}` : "Удалить категорию"}
title={block ? t("ai.cannot_delete_because", { reason: block }) : t("ai.delete_category")}
type="button"
onClick={() => (block
? setBlocked({ name: node.name, reason: block })
@@ -226,7 +224,7 @@ export function KnowledgeCategoriesPage({
<span className="knowledge-category-grip" />
<input
autoFocus
placeholder="Название категории"
placeholder={t("shared.category_name")}
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={(event) => {
@@ -234,8 +232,8 @@ export function KnowledgeCategoriesPage({
if (event.key === "Escape") cancel();
}}
/>
<button className="knowledge-category-save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>Готово</button>
<button className="knowledge-category-cancel" type="button" onClick={cancel}>Отмена</button>
<button className="knowledge-category-save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>{t("common.done")}</button>
<button className="knowledge-category-cancel" type="button" onClick={cancel}>{t("common.cancel")}</button>
</div>
)}
</div>
@@ -247,7 +245,7 @@ export function KnowledgeCategoriesPage({
<span className="knowledge-category-grip" />
<input
autoFocus
placeholder="Название категории"
placeholder={t("shared.category_name")}
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={(event) => {
@@ -255,35 +253,34 @@ export function KnowledgeCategoriesPage({
if (event.key === "Escape") cancel();
}}
/>
<button className="knowledge-category-save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>Готово</button>
<button className="knowledge-category-cancel" type="button" onClick={cancel}>Отмена</button>
<button className="knowledge-category-save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>{t("common.done")}</button>
<button className="knowledge-category-cancel" type="button" onClick={cancel}>{t("common.cancel")}</button>
</div>
)}
{canManage && (
<div className="knowledge-categories-foot">
<button type="button" onClick={() => { setEditingId(null); setCreateParentId(null); setDraftName(""); }}>
<Icon name="plus" size={14} strokeWidth={2} />Категория верхнего уровня
</button>
<Icon name="plus" size={14} strokeWidth={2} />{t("ai.top_level_category")}</button>
</div>
)}
</div>
<div className="knowledge-categories-side">
<div className="knowledge-rules-card">
<strong>Правила</strong>
<strong>{t("ai.rules")}</strong>
<ul>
<li>Имя уникально внутри родителя.</li>
<li>Категорию нельзя перенести внутрь себя.</li>
<li>Удаление — только для пустой категории без вложенных.</li>
<li>«Без категории» системная: не переименовывается и не удаляется.</li>
<li>{t("ai.name_unique_within_its_parent")}</li>
<li>{t("ai.category_cannot_moved_inside_itself")}</li>
<li>{t("ai.only_empty_category_with_nothing")}</li>
<li>{t("ai.no_category_system_one_cannot")}</li>
</ul>
</div>
{(blocked || error) && (
<div className="knowledge-warning-card">
<Icon name="alert" size={16} strokeWidth={2} />
<span>
{error || `«${blocked?.name}» удалить нельзя: ${blocked?.reason}. Сначала перенесите содержимое.`}
{error || t("ai.cannot_delete_named", { name: blocked?.name ?? "", reason: blocked?.reason ?? "" })}
</span>
</div>
)}
@@ -296,10 +293,10 @@ export function KnowledgeCategoriesPage({
onClose={() => setDeleting(null)}
tone="danger"
icon="trash"
title="Удалить категорию?"
description={deleting ? `Категория «${deleting.name}» пуста и будет удалена.` : ""}
title={t("shared.delete_category")}
description={deleting ? t("ai.category_empty_will_delete", { name: deleting.name }) : ""}
actions={<>
<Button variant="secondary" onClick={() => setDeleting(null)}>Отмена</Button>
<Button variant="secondary" onClick={() => setDeleting(null)}>{t("common.cancel")}</Button>
<Button
variant="danger-outline"
disabled={busy}
@@ -308,9 +305,7 @@ export function KnowledgeCategoriesPage({
setDeleting(null);
if (target) void run(() => deleteKnowledgeCategory(target.id));
}}
>
Удалить
</Button>
>{t("common.delete")}</Button>
</>}
/>
</section>
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff. Show more