diff --git a/AGENTS.md b/AGENTS.md index 72da480..6d322fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,14 @@ Engineering rules / обязательные практики: - Не инлайнить сложный UI повторно. Выносить компонент, если структура повторяется или секция имеет отдельную ответственность. - Если реализация по задаче конфликтует с этими правилами, остановиться и явно сообщить blocker вместо поставки плохой структуры. +Язык интерфейса: +- Текста, который видит человек, в коде не бывает. Строка живёт в словаре и подставляется через `t(...)`: во фронтенде это `src/i18n` приложения (`packages/ui` — свой словарь), на бэкенде `chatballs/i18n`. Русский каталог — источник ключей, английский обязан его повторить: пропущенный перевод во фронтенде ловит `tsc`, на бэкенде — тест каталога. +- Ключ пишется от смысла, а не от фразы: `settings.storage_bucket_name`, а не пересказ текста. Строку переформулируют чаще, чем переименовывают. +- Число не склоняют вручную: формы задаёт словарь, форму выбирает `tn(...)`. Дату, размер файла и разряды числа форматирует `fmt` — прибитые `"ru-RU"` и свои списки месяцев запрещены. +- Что записано в базу, переводится по коду, а не текстом: системные события диалога, уведомления и подписи журнала аудита хранят код, а фразу собирает бэкенд на языке читателя. Писать в историю готовую русскую фразу нельзя — её потом не перевести. +- Язык запроса выбирается цепочкой: профиль сотрудника → организация → установка → браузер. Письмо получает язык адресата, а не отправителя. +- На бэкенде `t(...)` не вызывается на уровне модуля: язык там свой на каждый запрос, а константа посчиталась бы один раз при импорте. + Definition of Done: - Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца. - Измененные файлы должны быть проверены на NO GOD violations. diff --git a/apps/backend/chatballs/ai/agent_card.py b/apps/backend/chatballs/ai/agent_card.py index 3497699..35f37dd 100644 --- a/apps/backend/chatballs/ai/agent_card.py +++ b/apps/backend/chatballs/ai/agent_card.py @@ -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: diff --git a/apps/backend/chatballs/ai/agent_card_views.py b/apps/backend/chatballs/ai/agent_card_views.py index 94a54f2..e5ba1bf 100644 --- a/apps/backend/chatballs/ai/agent_card_views.py +++ b/apps/backend/chatballs/ai/agent_card_views.py @@ -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, diff --git a/apps/backend/chatballs/ai/provider/demo.py b/apps/backend/chatballs/ai/provider/demo.py index 648207c..7d2e7fa 100644 --- a/apps/backend/chatballs/ai/provider/demo.py +++ b/apps/backend/chatballs/ai/provider/demo.py @@ -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")) diff --git a/apps/backend/chatballs/ai/provider/factory.py b/apps/backend/chatballs/ai/provider/factory.py index d470381..71b07f6 100644 --- a/apps/backend/chatballs/ai/provider/factory.py +++ b/apps/backend/chatballs/ai/provider/factory.py @@ -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) diff --git a/apps/backend/chatballs/ai/provider/openrouter.py b/apps/backend/chatballs/ai/provider/openrouter.py index 3343777..7b5852f 100644 --- a/apps/backend/chatballs/ai/provider/openrouter.py +++ b/apps/backend/chatballs/ai/provider/openrouter.py @@ -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 diff --git a/apps/backend/chatballs/ai/services.py b/apps/backend/chatballs/ai/services.py index e0a0e1c..d0b3b48 100644 --- a/apps/backend/chatballs/ai/services.py +++ b/apps/backend/chatballs/ai/services.py @@ -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 diff --git a/apps/backend/chatballs/api/pagination.py b/apps/backend/chatballs/api/pagination.py index d40b4ef..9080643 100644 --- a/apps/backend/chatballs/api/pagination.py +++ b/apps/backend/chatballs/api/pagination.py @@ -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) diff --git a/apps/backend/chatballs/calls/lifecycle.py b/apps/backend/chatballs/calls/lifecycle.py index 8c06170..e71f706 100644 --- a/apps/backend/chatballs/calls/lifecycle.py +++ b/apps/backend/chatballs/calls/lifecycle.py @@ -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 diff --git a/apps/backend/chatballs/calls/permissions.py b/apps/backend/chatballs/calls/permissions.py index fec2ea3..c1a5b03 100644 --- a/apps/backend/chatballs/calls/permissions.py +++ b/apps/backend/chatballs/calls/permissions.py @@ -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: diff --git a/apps/backend/chatballs/calls/public_access.py b/apps/backend/chatballs/calls/public_access.py index f1a2864..1430650 100644 --- a/apps/backend/chatballs/calls/public_access.py +++ b/apps/backend/chatballs/calls/public_access.py @@ -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: diff --git a/apps/backend/chatballs/calls/services.py b/apps/backend/chatballs/calls/services.py index 562fe86..d0b246a 100644 --- a/apps/backend/chatballs/calls/services.py +++ b/apps/backend/chatballs/calls/services.py @@ -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 diff --git a/apps/backend/chatballs/calls/tokens.py b/apps/backend/chatballs/calls/tokens.py index 0182e5d..16cee7f 100644 --- a/apps/backend/chatballs/calls/tokens.py +++ b/apps/backend/chatballs/calls/tokens.py @@ -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) diff --git a/apps/backend/chatballs/calls/views.py b/apps/backend/chatballs/calls/views.py index ac5cb56..7468a86 100644 --- a/apps/backend/chatballs/calls/views.py +++ b/apps/backend/chatballs/calls/views.py @@ -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)}) diff --git a/apps/backend/chatballs/channels/authorization.py b/apps/backend/chatballs/channels/authorization.py index 73966a1..9c4e859 100644 --- a/apps/backend/chatballs/channels/authorization.py +++ b/apps/backend/chatballs/channels/authorization.py @@ -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")) diff --git a/apps/backend/chatballs/channels/services.py b/apps/backend/chatballs/channels/services.py index 66b6fbb..cb07a94 100644 --- a/apps/backend/chatballs/channels/services.py +++ b/apps/backend/chatballs/channels/services.py @@ -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} diff --git a/apps/backend/chatballs/conversations/attachment_views.py b/apps/backend/chatballs/conversations/attachment_views.py index b4a9b19..b19d03d 100644 --- a/apps/backend/chatballs/conversations/attachment_views.py +++ b/apps/backend/chatballs/conversations/attachment_views.py @@ -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, diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index 6681410..145b0d3 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -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) diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index fc9cccd..17e8586 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -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}), diff --git a/apps/backend/chatballs/conversations/contacts_merge.py b/apps/backend/chatballs/conversations/contacts_merge.py index aa2b57b..f8e4d23 100644 --- a/apps/backend/chatballs/conversations/contacts_merge.py +++ b/apps/backend/chatballs/conversations/contacts_merge.py @@ -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 diff --git a/apps/backend/chatballs/conversations/ingest.py b/apps/backend/chatballs/conversations/ingest.py index d73e846..8c0c2d2 100644 --- a/apps/backend/chatballs/conversations/ingest.py +++ b/apps/backend/chatballs/conversations/ingest.py @@ -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", diff --git a/apps/backend/chatballs/conversations/migrations/0021_i18n_events.py b/apps/backend/chatballs/conversations/migrations/0021_i18n_events.py new file mode 100644 index 0000000..bfdeee3 --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0021_i18n_events.py @@ -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), + ), + ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index 2575930..5a8e8c5 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -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) diff --git a/apps/backend/chatballs/conversations/reporting_views.py b/apps/backend/chatballs/conversations/reporting_views.py index eb09ed4..6d44b7f 100644 --- a/apps/backend/chatballs/conversations/reporting_views.py +++ b/apps/backend/chatballs/conversations/reporting_views.py @@ -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: diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index 733f7db..8d54a20 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -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(), } diff --git a/apps/backend/chatballs/conversations/services.py b/apps/backend/chatballs/conversations/services.py index 695f388..c29d8a9 100644 --- a/apps/backend/chatballs/conversations/services.py +++ b/apps/backend/chatballs/conversations/services.py @@ -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 diff --git a/apps/backend/chatballs/conversations/transports/base.py b/apps/backend/chatballs/conversations/transports/base.py index 5d3d692..f95b058 100644 --- a/apps/backend/chatballs/conversations/transports/base.py +++ b/apps/backend/chatballs/conversations/transports/base.py @@ -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 diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index 522a602..5ea2f6f 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -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"]) diff --git a/apps/backend/chatballs/conversations/voice_views.py b/apps/backend/chatballs/conversations/voice_views.py index 97f3da2..a4c6d55 100644 --- a/apps/backend/chatballs/conversations/voice_views.py +++ b/apps/backend/chatballs/conversations/voice_views.py @@ -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, diff --git a/apps/backend/chatballs/i18n/__init__.py b/apps/backend/chatballs/i18n/__init__.py new file mode 100644 index 0000000..366f93f --- /dev/null +++ b/apps/backend/chatballs/i18n/__init__.py @@ -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", +] diff --git a/apps/backend/chatballs/i18n/catalog.py b/apps/backend/chatballs/i18n/catalog.py new file mode 100644 index 0000000..8e641d7 --- /dev/null +++ b/apps/backend/chatballs/i18n/catalog.py @@ -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) diff --git a/apps/backend/chatballs/i18n/languages.py b/apps/backend/chatballs/i18n/languages.py new file mode 100644 index 0000000..fe35262 --- /dev/null +++ b/apps/backend/chatballs/i18n/languages.py @@ -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 diff --git a/apps/backend/chatballs/i18n/messages/__init__.py b/apps/backend/chatballs/i18n/messages/__init__.py new file mode 100644 index 0000000..e91a95d --- /dev/null +++ b/apps/backend/chatballs/i18n/messages/__init__.py @@ -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"] diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py new file mode 100644 index 0000000..3996ae9 --- /dev/null +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -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", +} diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py new file mode 100644 index 0000000..a5789d8 --- /dev/null +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -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": "Диалог недоступен", +} diff --git a/apps/backend/chatballs/i18n/middleware.py b/apps/backend/chatballs/i18n/middleware.py new file mode 100644 index 0000000..fd31581 --- /dev/null +++ b/apps/backend/chatballs/i18n/middleware.py @@ -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() diff --git a/apps/backend/chatballs/i18n/tests.py b/apps/backend/chatballs/i18n/tests.py new file mode 100644 index 0000000..a7c2880 --- /dev/null +++ b/apps/backend/chatballs/i18n/tests.py @@ -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"]) diff --git a/apps/backend/chatballs/identity/administration_payloads.py b/apps/backend/chatballs/identity/administration_payloads.py index 54548e7..0ffee50 100644 --- a/apps/backend/chatballs/identity/administration_payloads.py +++ b/apps/backend/chatballs/identity/administration_payloads.py @@ -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 {}, diff --git a/apps/backend/chatballs/identity/administration_services.py b/apps/backend/chatballs/identity/administration_services.py index b13e3c7..c99264c 100644 --- a/apps/backend/chatballs/identity/administration_services.py +++ b/apps/backend/chatballs/identity/administration_services.py @@ -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) diff --git a/apps/backend/chatballs/identity/administration_views.py b/apps/backend/chatballs/identity/administration_views.py index d9dec80..d3dfc8b 100644 --- a/apps/backend/chatballs/identity/administration_views.py +++ b/apps/backend/chatballs/identity/administration_views.py @@ -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), }, diff --git a/apps/backend/chatballs/identity/audit_catalog.py b/apps/backend/chatballs/identity/audit_catalog.py index 4101ffb..f65d858 100644 --- a/apps/backend/chatballs/identity/audit_catalog.py +++ b/apps/backend/chatballs/identity/audit_catalog.py @@ -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 ] diff --git a/apps/backend/chatballs/identity/auth/__init__.py b/apps/backend/chatballs/identity/auth/__init__.py index f8d344d..bd72885 100644 --- a/apps/backend/chatballs/identity/auth/__init__.py +++ b/apps/backend/chatballs/identity/auth/__init__.py @@ -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", diff --git a/apps/backend/chatballs/identity/auth/common.py b/apps/backend/chatballs/identity/auth/common.py index 91906a4..5de8e2e 100644 --- a/apps/backend/chatballs/identity/auth/common.py +++ b/apps/backend/chatballs/identity/auth/common.py @@ -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, } diff --git a/apps/backend/chatballs/identity/auth/password_reset.py b/apps/backend/chatballs/identity/auth/password_reset.py index 460c533..3904c66 100644 --- a/apps/backend/chatballs/identity/auth/password_reset.py +++ b/apps/backend/chatballs/identity/auth/password_reset.py @@ -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: diff --git a/apps/backend/chatballs/identity/auth/profile.py b/apps/backend/chatballs/identity/auth/profile.py index e982963..6361b9c 100644 --- a/apps/backend/chatballs/identity/auth/profile.py +++ b/apps/backend/chatballs/identity/auth/profile.py @@ -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 "" diff --git a/apps/backend/chatballs/identity/auth/sessions.py b/apps/backend/chatballs/identity/auth/sessions.py index 5f28eab..b91fc40 100644 --- a/apps/backend/chatballs/identity/auth/sessions.py +++ b/apps/backend/chatballs/identity/auth/sessions.py @@ -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)}) diff --git a/apps/backend/chatballs/identity/auth_urls.py b/apps/backend/chatballs/identity/auth_urls.py index 1d6fc9b..c75fe46 100644 --- a/apps/backend/chatballs/identity/auth_urls.py +++ b/apps/backend/chatballs/identity/auth_urls.py @@ -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"), diff --git a/apps/backend/chatballs/identity/avatars.py b/apps/backend/chatballs/identity/avatars.py index 96a4580..883506f 100644 --- a/apps/backend/chatballs/identity/avatars.py +++ b/apps/backend/chatballs/identity/avatars.py @@ -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) diff --git a/apps/backend/chatballs/identity/emails.py b/apps/backend/chatballs/identity/emails.py index 62c3062..1a63c29 100644 --- a/apps/backend/chatballs/identity/emails.py +++ b/apps/backend/chatballs/identity/emails.py @@ -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(), + ) diff --git a/apps/backend/chatballs/identity/employee_security_views.py b/apps/backend/chatballs/identity/employee_security_views.py index 778673c..3278f3b 100644 --- a/apps/backend/chatballs/identity/employee_security_views.py +++ b/apps/backend/chatballs/identity/employee_security_views.py @@ -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( diff --git a/apps/backend/chatballs/identity/employee_views.py b/apps/backend/chatballs/identity/employee_views.py index cd7f40a..f84c7c1 100644 --- a/apps/backend/chatballs/identity/employee_views.py +++ b/apps/backend/chatballs/identity/employee_views.py @@ -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", diff --git a/apps/backend/chatballs/identity/group_views.py b/apps/backend/chatballs/identity/group_views.py index e9a35dd..cb87bed 100644 --- a/apps/backend/chatballs/identity/group_views.py +++ b/apps/backend/chatballs/identity/group_views.py @@ -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") ) diff --git a/apps/backend/chatballs/identity/instance_settings.py b/apps/backend/chatballs/identity/instance_settings.py index 67f74ea..b4430b5 100644 --- a/apps/backend/chatballs/identity/instance_settings.py +++ b/apps/backend/chatballs/identity/instance_settings.py @@ -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: """Запомнить адрес, на котором прошли мастер, если он ещё не задан.""" diff --git a/apps/backend/chatballs/identity/instance_views.py b/apps/backend/chatballs/identity/instance_views.py index b52ddd6..36f88b4 100644 --- a/apps/backend/chatballs/identity/instance_views.py +++ b/apps/backend/chatballs/identity/instance_views.py @@ -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: diff --git a/apps/backend/chatballs/identity/migrations/0035_language_settings.py b/apps/backend/chatballs/identity/migrations/0035_language_settings.py new file mode 100644 index 0000000..96dc01c --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0035_language_settings.py @@ -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), + ), + ] diff --git a/apps/backend/chatballs/identity/models.py b/apps/backend/chatballs/identity/models.py index 9fc9e0a..74b13e3 100644 --- a/apps/backend/chatballs/identity/models.py +++ b/apps/backend/chatballs/identity/models.py @@ -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, diff --git a/apps/backend/chatballs/identity/password_validation.py b/apps/backend/chatballs/identity/password_validation.py index b0db1e6..a7c43cb 100644 --- a/apps/backend/chatballs/identity/password_validation.py +++ b/apps/backend/chatballs/identity/password_validation.py @@ -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 "Пароль должен содержать букву, цифру и спецсимвол." diff --git a/apps/backend/chatballs/identity/setup.py b/apps/backend/chatballs/identity/setup.py index a67c077..bcc54ea 100644 --- a/apps/backend/chatballs/identity/setup.py +++ b/apps/backend/chatballs/identity/setup.py @@ -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()) diff --git a/apps/backend/chatballs/identity/setup_views.py b/apps/backend/chatballs/identity/setup_views.py index 0c185ee..8b81fbf 100644 --- a/apps/backend/chatballs/identity/setup_views.py +++ b/apps/backend/chatballs/identity/setup_views.py @@ -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 diff --git a/apps/backend/chatballs/identity/test_administration_api.py b/apps/backend/chatballs/identity/test_administration_api.py index 1bb9a00..ca559f3 100644 --- a/apps/backend/chatballs/identity/test_administration_api.py +++ b/apps/backend/chatballs/identity/test_administration_api.py @@ -61,6 +61,8 @@ class AdministrationApiTests(TestCase): "name": "Example", "timezone": "Europe/Moscow", "currency": "RUB", + # Пустой язык — «как в установке»: организация своего не выбрала. + "language": "", "logoUrl": None, # Подпись «Сохранено …» у кнопки (кадр N1): пока правок не было — null. "updatedAt": None, diff --git a/apps/backend/chatballs/identity/tests.py b/apps/backend/chatballs/identity/tests.py index 1aa9b24..9dd3b09 100644 --- a/apps/backend/chatballs/identity/tests.py +++ b/apps/backend/chatballs/identity/tests.py @@ -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") diff --git a/apps/backend/chatballs/integrations/feature_views.py b/apps/backend/chatballs/integrations/feature_views.py index 2473a7a..cd136f6 100644 --- a/apps/backend/chatballs/integrations/feature_views.py +++ b/apps/backend/chatballs/integrations/feature_views.py @@ -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: diff --git a/apps/backend/chatballs/integrations/proxy.py b/apps/backend/chatballs/integrations/proxy.py index 214dc27..4b4130b 100644 --- a/apps/backend/chatballs/integrations/proxy.py +++ b/apps/backend/chatballs/integrations/proxy.py @@ -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): diff --git a/apps/backend/chatballs/integrations/views.py b/apps/backend/chatballs/integrations/views.py index 84bc8de..3b13d7e 100644 --- a/apps/backend/chatballs/integrations/views.py +++ b/apps/backend/chatballs/integrations/views.py @@ -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 ) diff --git a/apps/backend/chatballs/notifications/migrations/0010_i18n_events.py b/apps/backend/chatballs/notifications/migrations/0010_i18n_events.py new file mode 100644 index 0000000..098842d --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0010_i18n_events.py @@ -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), + ), + ] diff --git a/apps/backend/chatballs/notifications/models.py b/apps/backend/chatballs/notifications/models.py index 700715c..193edeb 100644 --- a/apps/backend/chatballs/notifications/models.py +++ b/apps/backend/chatballs/notifications/models.py @@ -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) diff --git a/apps/backend/chatballs/notifications/serializers.py b/apps/backend/chatballs/notifications/serializers.py index 59f11e2..d8f70b7 100644 --- a/apps/backend/chatballs/notifications/serializers.py +++ b/apps/backend/chatballs/notifications/serializers.py @@ -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(), diff --git a/apps/backend/chatballs/notifications/services.py b/apps/backend/chatballs/notifications/services.py index 1de1bb1..41194b6 100644 --- a/apps/backend/chatballs/notifications/services.py +++ b/apps/backend/chatballs/notifications/services.py @@ -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 "", diff --git a/apps/backend/chatballs/notifications/views.py b/apps/backend/chatballs/notifications/views.py index cc1576c..57c03a2 100644 --- a/apps/backend/chatballs/notifications/views.py +++ b/apps/backend/chatballs/notifications/views.py @@ -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) diff --git a/apps/backend/chatballs/support_portals/addressing.py b/apps/backend/chatballs/support_portals/addressing.py index 7bc9e74..f1c4729 100644 --- a/apps/backend/chatballs/support_portals/addressing.py +++ b/apps/backend/chatballs/support_portals/addressing.py @@ -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 diff --git a/apps/backend/chatballs/support_portals/article_import_views.py b/apps/backend/chatballs/support_portals/article_import_views.py index a854b3d..98583a4 100644 --- a/apps/backend/chatballs/support_portals/article_import_views.py +++ b/apps/backend/chatballs/support_portals/article_import_views.py @@ -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) diff --git a/apps/backend/chatballs/support_portals/content_services.py b/apps/backend/chatballs/support_portals/content_services.py index 96f8009..73c05aa 100644 --- a/apps/backend/chatballs/support_portals/content_services.py +++ b/apps/backend/chatballs/support_portals/content_services.py @@ -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: diff --git a/apps/backend/chatballs/support_portals/content_views.py b/apps/backend/chatballs/support_portals/content_views.py index 324f163..387b367 100644 --- a/apps/backend/chatballs/support_portals/content_views.py +++ b/apps/backend/chatballs/support_portals/content_views.py @@ -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 diff --git a/apps/backend/chatballs/support_portals/portal_views.py b/apps/backend/chatballs/support_portals/portal_views.py index 28a134f..1d244e5 100644 --- a/apps/backend/chatballs/support_portals/portal_views.py +++ b/apps/backend/chatballs/support_portals/portal_views.py @@ -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: diff --git a/apps/backend/chatballs/support_portals/public_views.py b/apps/backend/chatballs/support_portals/public_views.py index 0af2129..d1c740c 100644 --- a/apps/backend/chatballs/support_portals/public_views.py +++ b/apps/backend/chatballs/support_portals/public_views.py @@ -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) diff --git a/apps/backend/chatballs/tenancy/storage_migration.py b/apps/backend/chatballs/tenancy/storage_migration.py index e80e544..c74cc7f 100644 --- a/apps/backend/chatballs/tenancy/storage_migration.py +++ b/apps/backend/chatballs/tenancy/storage_migration.py @@ -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) diff --git a/apps/backend/chatballs/tenancy/storage_settings.py b/apps/backend/chatballs/tenancy/storage_settings.py index 3ce4826..6d082c2 100644 --- a/apps/backend/chatballs/tenancy/storage_settings.py +++ b/apps/backend/chatballs/tenancy/storage_settings.py @@ -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 diff --git a/apps/backend/chatballs/tenancy/storage_views.py b/apps/backend/chatballs/tenancy/storage_views.py index 5e3fa0f..0eea217 100644 --- a/apps/backend/chatballs/tenancy/storage_views.py +++ b/apps/backend/chatballs/tenancy/storage_views.py @@ -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 diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py index 3f4b0fa..1cc1915 100644 --- a/apps/backend/chatballs/webchat/services.py +++ b/apps/backend/chatballs/webchat/services.py @@ -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, diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py index 1bc3329..8299dff 100644 --- a/apps/backend/chatballs/webchat/views.py +++ b/apps/backend/chatballs/webchat/views.py @@ -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}) diff --git a/apps/backend/chatballs_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py index 8e3629e..d344765 100644 --- a/apps/backend/chatballs_backend/settings_base.py +++ b/apps/backend/chatballs_backend/settings_base.py @@ -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 diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index b15daa4..a02f579 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -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(null); const [organizationPublicId, setOrganizationPublicId] = useState(initialRoute.organizationPublicId); const [totpChallenge, setTotpChallenge] = useState(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 ( - + { setResetting(false); window.history.replaceState({}, "", pathFromRoute("chat")); }} /> ); } - if (sessionLoading) return ; + if (sessionLoading) return ; return ( - + {totpChallenge ? ( { setTotpChallenge(null); landAfterAuth(nextUser); }} /> ) : !identity ? ( diff --git a/apps/internal-ui/src/api/client.ts b/apps/internal-ui/src/api/client.ts index a6aed6f..d999148 100644 --- a/apps/internal-ui/src/api/client.ts +++ b/apps/internal-ui/src/api/client.ts @@ -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 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(path: string, init: RequestInit = {}): Promise { 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(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(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); }); diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index 7128276..e2cd1d3 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -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", diff --git a/apps/internal-ui/src/features/administration/AuditFilters.tsx b/apps/internal-ui/src/features/administration/AuditFilters.tsx index efbd1bd..2c3cdfe 100644 --- a/apps/internal-ui/src/features/administration/AuditFilters.tsx +++ b/apps/internal-ui/src/features/administration/AuditFilters.tsx @@ -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 (
setQuery({ q: value })} /> ({ value, label }))} @@ -49,27 +50,27 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: { onSelect={(value) => setQuery({ period: value as AuditPeriod })} /> setQuery({ category: value })} /> {actors.length > 1 && ( setQuery({ actor: 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} - > - Сбросить - + >{t("common.reset")}
); } diff --git a/apps/internal-ui/src/features/administration/AuditPage.tsx b/apps/internal-ui/src/features/administration/AuditPage.tsx index 4dc4aac..0a0c074 100644 --- a/apps/internal-ui/src/features/administration/AuditPage.tsx +++ b/apps/internal-ui/src/features/administration/AuditPage.tsx @@ -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 = ( ); @@ -30,7 +30,7 @@ export function AuditPage() {
{header} - +
); } @@ -56,13 +56,13 @@ export function AuditPage() { таблица моргает пустотой на каждый ввод в поиске. */}
{payload.items.length === 0 - ? + ? : }
{payload.pageCount > 1 && ( setQuery({ page })} diff --git a/apps/internal-ui/src/features/administration/AuditTable.tsx b/apps/internal-ui/src/features/administration/AuditTable.tsx index 905dbc2..c9af316 100644 --- a/apps/internal-ui/src/features/administration/AuditTable.tsx +++ b/apps/internal-ui/src/features/administration/AuditTable.tsx @@ -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[] }) { - - - - - + + + + + diff --git a/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx b/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx index b05ab2f..ad28872 100644 --- a/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx +++ b/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx @@ -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({
{logoUrl - ? {`Логотип + ? {t("admin.organization_logo_alt", : {name.trim().slice(0, 2).toUpperCase() || "CR"}}
- Логотип - PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах. + {t("admin.logo")} + {t("admin.png_jpeg_or_webp_up")}
{!disabled && (
@@ -49,17 +50,13 @@ export function OrganizationLogoField({ variant="secondary" disabled={saving} onClick={() => input.current?.click()} - > - Загрузить - + >{t("common.upload")} + >{t("common.remove")}
)} setConfirmingRemoval(false)} tone="danger" icon="trash" - title="Удалить логотип?" - description="Вместо него в меню снова будет показан знак Chatballs." + title={t("admin.remove_logo")} + description={t("admin.chatballs_mark_will_shown_menu")} actions={( <> - + + >{t("common.delete")} )} /> diff --git a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx index c105348..e2560ba 100644 --- a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx +++ b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx @@ -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({
onChange({ ...organization, timezone })} options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} /> onChange({ ...organization, language })} + // Первый пункт — не язык, а отказ от выбора: организация без своего + // языка следует за установкой, и владелец видит это словами. + options={[ + ["", t("settings.language_as_installation")], + ...languages.map((item): [string, string] => [item.code, item.label]), + ]} + hint={t("settings.language_org_hint")} + /> + onChange({ ...organization, currency })} - options={[["RUB", "Российский рубль (RUB)"]]} + options={[["RUB", t("admin.russian_rouble_rub")]]} />
{error &&
{error}
} @@ -85,7 +102,7 @@ export function OrganizationSettingsForm({ variant="primary" disabled={saving || !organization.name.trim()} > - {saving ? "Сохранение" : "Сохранить"} + {saving ? t("common.saving") : t("common.save")}
)} diff --git a/apps/internal-ui/src/features/administration/api.ts b/apps/internal-ui/src/features/administration/api.ts index ab8c593..0379d6f 100644 --- a/apps/internal-ui/src/features/administration/api.ts +++ b/apps/internal-ui/src/features/administration/api.ts @@ -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; diff --git a/apps/internal-ui/src/features/administration/model.ts b/apps/internal-ui/src/features/administration/model.ts index 0dea2ce..3595ca2 100644 --- a/apps/internal-ui/src/features/administration/model.ts +++ b/apps/internal-ui/src/features/administration/model.ts @@ -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 = { diff --git a/apps/internal-ui/src/features/administration/useAdministration.ts b/apps/internal-ui/src/features/administration/useAdministration.ts index b45b4ac..8866cd6 100644 --- a/apps/internal-ui/src/features/administration/useAdministration.ts +++ b/apps/internal-ui/src/features/administration/useAdministration.ts @@ -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(null); const [timezones, setTimezones] = useState([]); + const [languages, setLanguages] = useState([]); 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"), ), }; } diff --git a/apps/internal-ui/src/features/administration/useAudit.ts b/apps/internal-ui/src/features/administration/useAudit.ts index ca9eca0..2232761 100644 --- a/apps/internal-ui/src/features/administration/useAudit.ts +++ b/apps/internal-ui/src/features/administration/useAudit.ts @@ -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(load, filters, "Не удалось загрузить журнал"); + const journal = usePagedResource(load, filters, t("admin.could_not_load_log")); const setQuery = useCallback((patch: Partial) => { // Страницу двигает подвал списка, фильтры — состояние запроса; смена diff --git a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx index 4875219..9b5e34b 100644 --- a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx +++ b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx @@ -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 ; + if (missing) return ; if (failed) return void reload()} />; if (!card) return ; @@ -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: }, + { key: "copy-code", label: }, { type: "divider" as const }, { key: "active", disabled: busy, label: ( ), }, @@ -232,15 +233,14 @@ export function AgentDetailPage({ disabled: busy, label: ( + {t("ai.delete_agent")} ), }, ]; return (
- +
@@ -252,22 +252,22 @@ export function AgentDetailPage({ {status.text}

- {card.groupName ?? "Без группы"} + {card.groupName ?? t("common.no_group")} {card.code} {openDialogsLine(card.counters.openConversations)} - создан {createdLabel(card.createdAt)} + {t("ai.created_on", { date: createdLabel(card.createdAt) })}

{canManage && (
- +
)} @@ -277,11 +277,11 @@ export function AgentDetailPage({
- AI не может отвечать: провайдер не выбран - Добавьте ключ в «Настройки → AI-провайдер» и выберите провайдера в блоке «Модель». До этого диалоги агента ждут человека. + {t("ai.ai_cannot_reply_no_provider")} + {t("ai.add_key_under_settings_ai")}
{canManage && ( - + )}
)} @@ -311,14 +311,14 @@ export function AgentDetailPage({ {deleting && ( void removeAgent()} onCancel={() => setDeleting(false)} > -

Агент «{card.name}» будет удалён безвозвратно. Диалоги и подключения не дают удалить агента.

+

{t("ai.agent_will_be_deleted", { name: card.name })}

)} @@ -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 (
-

Инструкции

- Системный промпт собирается из трёх частей в этом порядке +

{t("ai.instructions_2")}

+ {t("ai.system_prompt_assembled_from_three")}
{INSTRUCTION_FIELDS.map((field) => ( @@ -369,9 +369,9 @@ function InstructionsCard({ card, canManage, busy, apply }: {
{canManage && dirty && (
- Есть несохранённые изменения — применятся в runtime сразу - - + {t("ai.there_unsaved_changes_they_take")} + +
)}
@@ -398,15 +398,14 @@ function KnowledgeCard({ card, canManage, openKnowledge, reload }: { return (
-

Знания

{knowledgeLine(card)}
+

{t("ai.knowledge")}

{knowledgeLine(card)}
{canManage && ( + {t("ai.select")} )}
{rows.length === 0 ? ( -

Знания не прикреплены — агент отвечает только по инструкциям. Выберите статьи из библиотеки или портала поддержки.

+

{t("ai.no_knowledge_attached_so_agent")}

) : (
{rows.map((row) => ( @@ -418,7 +417,7 @@ function KnowledgeCard({ card, canManage, openKnowledge, reload }: { {row.chip && {row.chip}} {row.meta} {canManage && ( - )} @@ -460,17 +459,17 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: { return (
-

Назначение

-

Группа определяет, кто из сотрудников видит диалоги агента; без группы — видны всем.

+

{t("ai.assignment")}

+

{t("ai.group_decides_which_operators_see")}

- Правила + {t("ai.rules")}
    -
  • Имя уникально внутри родителя.
  • -
  • Категорию нельзя перенести внутрь себя.
  • -
  • Удаление — только для пустой категории без вложенных.
  • -
  • «Без категории» системная: не переименовывается и не удаляется.
  • +
  • {t("ai.name_unique_within_its_parent")}
  • +
  • {t("ai.category_cannot_moved_inside_itself")}
  • +
  • {t("ai.only_empty_category_with_nothing")}
  • +
  • {t("ai.no_category_system_one_cannot")}
{(blocked || error) && (
- {error || `«${blocked?.name}» удалить нельзя: ${blocked?.reason}. Сначала перенесите содержимое.`} + {error || t("ai.cannot_delete_named", { name: blocked?.name ?? "", reason: blocked?.reason ?? "" })}
)} @@ -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={<> - + + >{t("common.delete")} } />
diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryDialog.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryDialog.tsx index 9b557a2..cb9f731 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryDialog.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryDialog.tsx @@ -3,10 +3,10 @@ import { useState } from "react"; import { Icon } from "../../../shared/icons"; import { Button } from "../../../shared/ui-controls"; -import { pluralRu } from "../../../shared/utils"; import { categoryRows } from "./knowledgeLibraryModel"; import { knowledgeCategoryPath } from "./knowledgeTree"; import type { KnowledgeCategory } from "./types"; +import { t, tn } from "../../../i18n"; // «Переместить в категорию» из панели массовых операций (кадр KB2). Категории // не создаются на лету: список — то же дерево, что и слева в библиотеке. @@ -32,17 +32,17 @@ export function KnowledgeCategoryDialog({
-

Переместить в категорию

-

Выбрано {pluralRu(count, ["знание", "знания", "знаний"])}. Категория задаёт размещение в дереве, на ответы агента она не влияет.

+

{t("ai.move_category")}

+

{t("ai.selected_category_note", { count: tn("plural.knowledge", count) })}

-
- КАТЕГОРИЯ + {t("common.category")}
{rows.map(({ category, depth, count: knowledgeCount }) => ( - + + ); diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx index 277ed45..25250a6 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx @@ -31,6 +31,7 @@ import { type KnowledgeItem, } from "./model"; import { useKnowledgeCategories } from "./useKnowledgeCategories"; +import { fmt, t, tn } from "../../../i18n"; // Редактор знания (дизайн-базлайн v2, кадры KB5/KB6): тот же полноэкранный // split, что у статьи портала, — текст с панелью Markdown, предпросмотр и @@ -39,28 +40,28 @@ import { useKnowledgeCategories } from "./useKnowledgeCategories"; type Mode = "edit" | "split" | "view"; const MODES: Array<[Mode, string, "edit" | "split" | "eye", string]> = [ - ["edit", "Текст", "edit", "Только редактор"], - ["split", "Вместе", "split", "Текст и предпросмотр"], - ["view", "Просмотр", "eye", "Только предпросмотр"], + ["edit", t("ai.text"), "edit", t("ai.editor_only")], + ["split", t("ai.side_by_side"), "split", t("ai.text_preview")], + ["view", t("ai.preview"), "eye", t("ai.preview_only")], ]; // Панель Markdown знания (кадр KB5): без кнопки «изображение» — любой файл // прикрепляется к знанию и ставится в текст ссылкой. const KNOWLEDGE_MARKDOWN_TOOLS: MarkdownTool[] = [ - { key: "h1", title: "Заголовок 1", text: "H1" }, - { key: "h2", title: "Заголовок 2", text: "H2" }, + { key: "h1", title: t("ai.heading_1"), text: "H1" }, + { key: "h2", title: t("ai.heading_2"), text: "H2" }, { key: "divider-1", title: "", divider: true }, - { key: "bold", title: "Полужирный", icon: "bold" }, - { key: "italic", title: "Курсив", icon: "italic" }, - { key: "link", title: "Ссылка", icon: "link" }, - { key: "code", title: "Код", icon: "code" }, + { key: "bold", title: t("ai.bold"), icon: "bold" }, + { key: "italic", title: t("ai.italic"), icon: "italic" }, + { key: "link", title: t("ai.link"), icon: "link" }, + { key: "code", title: t("ai.code"), icon: "code" }, { key: "divider-2", title: "", divider: true }, - { key: "list", title: "Список", icon: "list" }, - { key: "numlist", title: "Нумерованный список", icon: "numlist" }, - { key: "quote", title: "Цитата", icon: "quote" }, - { key: "table", title: "Таблица", icon: "table" }, + { key: "list", title: t("ai.list"), icon: "list" }, + { key: "numlist", title: t("ai.numbered_list"), icon: "numlist" }, + { key: "quote", title: t("ai.quote"), icon: "quote" }, + { key: "table", title: t("ai.table"), icon: "table" }, { key: "divider-3", title: "", divider: true }, - { key: "attach", title: "Прикрепить файл", icon: "attach", accent: true }, + { key: "attach", title: t("common.attach_file"), icon: "attach", accent: true }, ]; function draftKey(knowledgeId: number | null): string { @@ -127,7 +128,7 @@ export function KnowledgeEditorPage({ categoryId: String(knowledge.category.id), }); } catch { - setError("Не удалось загрузить знание"); + setError(t("ai.could_not_load_knowledge_item")); } finally { setBusy(false); } @@ -205,7 +206,7 @@ export function KnowledgeEditorPage({ setAttachments((current) => [attachment, ...current.filter((item) => item.id !== attachment.id)]); if (insert) insertText(`\n${attachmentMarkdown(attachment)}\n`); } catch (caught) { - setError(caught instanceof Error ? caught.message : `Не удалось загрузить ${file.name}`); + setError(caught instanceof Error ? caught.message : t("ai.could_not_upload_file", { name: file.name })); } finally { setUploading(null); } @@ -219,12 +220,12 @@ export function KnowledgeEditorPage({ await deleteAttachment(loaded.id, attachment.id); setAttachments((current) => current.filter((item) => item.id !== attachment.id)); } catch { - setError("Не удалось удалить вложение"); + setError(t("ai.could_not_delete_attachment")); } } async function save() { - if (!ready) { setError("Укажите заголовок и категорию"); return; } + if (!ready) { setError(t("ai.give_title_category")); return; } setBusy(true); setError(""); try { @@ -249,7 +250,7 @@ export function KnowledgeEditorPage({ openKnowledge(knowledge.id); } } catch (caught) { - setError(caught instanceof Error ? caught.message : "Не удалось сохранить знание"); + setError(caught instanceof Error ? caught.message : t("ai.could_not_save_knowledge_item")); } finally { setBusy(false); } @@ -271,7 +272,7 @@ export function KnowledgeEditorPage({ } if (catalog.loading) return
; - if (catalog.error) return
; + if (catalog.error) return
; const chunks = knowledgeChunks(content); @@ -279,23 +280,18 @@ export function KnowledgeEditorPage({
+ {t("common.knowledge_base")} {categoryPath} - {dirty ? "Есть несохранённые правки" : "Индекс актуален"} + {dirty ? t("ai.there_unsaved_edits") : t("ai.index_up_date")} {canManage && ( - + )} {canManage && ( - + )}
@@ -306,11 +302,11 @@ export function KnowledgeEditorPage({
setTitle(event.target.value)} /> - Заголовок цитируется агентом в ответе клиенту — пишите его как ответ, не как папку. + {t("ai.agent_quotes_title_its_reply")}
@@ -366,8 +362,8 @@ export function KnowledgeEditorPage({ {dropping && (
- Отпустите, чтобы приложить к знанию - Файл станет вложением и ссылкой в тексте — агент отдаёт её клиенту. PNG, JPG, PDF, DOCX, XLSX · до 25 МБ + {t("ai.drop_attach_item")} + {t("ai.file_becomes_attachment_link_text")}
)}
@@ -378,10 +374,10 @@ export function KnowledgeEditorPage({ {dirty - ? "Правки не сохранены — фрагменты пересоберутся после «Сохранить»" + ? t("ai.edits_not_saved_chunks_rebuilt") : autoSavedAt - ? `Сохранено автоматически в ${autoSavedAt.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}` - : "Всё сохранено"} + ? t("time.autosaved_at", { time: fmt.time(autoSavedAt) }) + : t("ai.everything_saved")}
@@ -390,35 +386,33 @@ export function KnowledgeEditorPage({ {mode !== "edit" && (
- ПРЕДПРОСМОТР · {asAgent ? "КАК ОТВЕТИТ АГЕНТ" : "ВНУТРЕННИЙ МАТЕРИАЛ"} + {t("ai.preview_of", { what: asAgent ? t("ai.how_agent_will_answer") : t("ai.internal_material") })} + {t("ai.how_agent_will_answer_2")}
{asAgent ? (

- Агент отвечает не текстом целиком, а подходящими фрагментами. Так знание разойдётся - на {chunks.length} {chunks.length === 1 ? "фрагмент" : "фрагментов"} при сохранении. + {t("ai.fragments_note", { chunks: tn("plural.chunks", chunks.length) })}

{chunks.map((chunk, index) => (
-
Фрагмент {index + 1}{chunk.length} знаков
+
{t("ai.fragment_number", { number: index + 1 })}{t("ai.characters_count", { count: fmt.number(chunk.length) })}

{chunk}

))} - {chunks.length === 0 &&

Пустое знание в ответы не попадает.

} + {chunks.length === 0 &&

{t("ai.empty_item_never_reaches_replies")}

}
) : (
-

{title || "Без заголовка"}

+

{title || t("ai.untitled")}

{description &&

{description}

} {attachments.map((attachment) => ( @@ -426,7 +420,7 @@ export function KnowledgeEditorPage({ {attachment.name} - вложение · ссылку агент отдаёт клиенту + {t("ai.attachment_agent_hands_link_customer")}
))} @@ -439,7 +433,7 @@ export function KnowledgeEditorPage({
ВремяСотрудникДействиеОбъектРезультат{t("admin.time")}{t("common.operator")}{t("common.action")}{t("common.object")}{t("admin.result")}