mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🌐 feat(i18n): язык интерфейса — русский и английский
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API, даты форматировались прибитым «ru-RU», склонение по числу писалось руками в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык выбирается человеком. Цепочка одна на обе стороны: профиль сотрудника → организация → установка → браузер. Пустое значение на каждом уровне значит «как выше», а не «русский»: тот, кто язык не трогал, поедет за организацией, когда владелец её переключит, а выбравший явно — останется на своём. Владелец задаёт язык организации («Настройки» → «Организация», рядом с часовым поясом) и язык установки («Платформа») — на нём открываются вход, сброс пароля и мастер первого запуска. Сотрудник переопределяет его в профиле. Русский каталог задаёт набор ключей, английский обязан его повторить: во фронтенде это ловит tsc — пропущенный перевод становится ошибкой сборки, — на бэкенде тест каталога, который заодно сверяет имена параметров в фразах. Формы множественного числа берутся из CLDR через Intl.PluralRules, даты и размеры — через Intl, а не через свои списки месяцев. Отдельно пришлось разобраться с тем, что уже записано в базу. Системные события диалога писались готовой русской фразой, и перевести историю задним числом нельзя — теперь пишется код события, а фразу собирает сервер на языке читателя. То же с уведомлениями: они адресованы операторам, а не одному человеку, и в смешанной команде готовая фраза неверна для половины. Тон системной строки в треде и признак анонимного посетителя больше не угадываются регуляркой по русским словам: под английским бэкендом это просто перестало бы работать. Письма уходят на языке получателя, а не отправителя: приглашение отправляет владелец, а читает другой человек. gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа ради того же результата, что дают обычные словари, одинаковые на обеих сторонах. Язык запроса при этом активируется штатным механизмом Django, поэтому сообщения DRF переводятся тоже. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f91b3da27a
commit
6c3dec7339
282 files changed
+8185
-2561
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,7 @@ from chatballs.ai.provider.base import (
|
||||
LLMProvider,
|
||||
ProviderError,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
|
||||
EMBEDDING_DIM = 16
|
||||
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
|
||||
@@ -131,4 +132,4 @@ class DemoProvider(LLMProvider):
|
||||
]
|
||||
|
||||
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
|
||||
raise ProviderError("Расшифровка голосовых недоступна в демо-провайдере: подключите OpenRouter или совместимый провайдер в «Настройках».")
|
||||
raise ProviderError(t("ai.demo_no_transcription"))
|
||||
@@ -4,6 +4,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
from chatballs.ai.provider import routing
|
||||
from chatballs.ai.provider.base import LLMProvider, ProviderError
|
||||
from chatballs.ai.provider.local import LocalProvider
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
def _test_provider() -> LLMProvider:
|
||||
@@ -26,6 +27,6 @@ def get_provider(*, channel=None) -> LLMProvider:
|
||||
|
||||
if channel is None:
|
||||
raise ProviderError(
|
||||
"AI-провайдер не настроен: вызов без канала не может выбрать интеграцию"
|
||||
t("ai.provider_not_configured")
|
||||
)
|
||||
return routing.resolve_provider(channel)
|
||||
@@ -1,5 +1,6 @@
|
||||
from chatballs.ai.provider import openai_http
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
class OpenRouterProvider(LLMProvider):
|
||||
@@ -70,5 +71,5 @@ class OpenRouterProvider(LLMProvider):
|
||||
raise ProviderError(f"Расшифровка не удалась: {error}") from error
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ProviderError("Провайдер вернул пустую расшифровку")
|
||||
raise ProviderError(t("ai.empty_transcript"))
|
||||
return text
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,7 @@ from chatballs.calls.services import (
|
||||
resolve_invite,
|
||||
)
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
|
||||
@@ -49,16 +50,16 @@ class CallCreateView(APIView):
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
if "kind" not in request.data:
|
||||
return Response({"detail": "Укажите тип звонка"}, status=400)
|
||||
return Response({"detail": t("calls.kind_required")}, status=400)
|
||||
kind = str(request.data.get("kind", ""))
|
||||
if kind not in CallKind.values:
|
||||
return Response({"detail": "Недопустимый тип звонка"}, status=400)
|
||||
return Response({"detail": t("calls.kind_invalid")}, status=400)
|
||||
try:
|
||||
created = create_call_request(
|
||||
context=request.tenant_context, conversation_id=conversation_id, kind=kind
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
except CallAccessDenied as error:
|
||||
return Response({"detail": str(error)}, status=403)
|
||||
except CallConflict as error:
|
||||
@@ -95,7 +96,7 @@ class CallDetailView(APIView):
|
||||
id=call_session_id, organization=request.tenant_context.organization
|
||||
)
|
||||
except CallSession.DoesNotExist:
|
||||
return Response({"detail": "Звонок не найден"}, status=404)
|
||||
return Response({"detail": t("calls.not_found")}, status=404)
|
||||
try:
|
||||
ensure_call_access(user=request.tenant_context.membership, call_session=call)
|
||||
except CallAccessDenied as error:
|
||||
@@ -113,7 +114,7 @@ class StaffAccessTokenView(APIView):
|
||||
)
|
||||
token = issue_staff_access_token(context=request.tenant_context, call_session=call)
|
||||
except CallSession.DoesNotExist:
|
||||
return Response({"detail": "Звонок не найден"}, status=404)
|
||||
return Response({"detail": t("calls.not_found")}, status=404)
|
||||
except CallAccessDenied as error:
|
||||
return Response({"detail": str(error)}, status=403)
|
||||
except CallConflict as error:
|
||||
@@ -131,7 +132,7 @@ class CallCancelView(APIView):
|
||||
)
|
||||
cancel_call(context=request.tenant_context, call_session=call)
|
||||
except CallSession.DoesNotExist:
|
||||
return Response({"detail": "Звонок не найден"}, status=404)
|
||||
return Response({"detail": t("calls.not_found")}, status=404)
|
||||
except CallAccessDenied as error:
|
||||
return Response({"detail": str(error)}, status=403)
|
||||
except CallConflict as error:
|
||||
@@ -163,7 +164,7 @@ class ConversationActiveCallView(APIView):
|
||||
user=request.tenant_context.membership, conversation=conversation
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
except CallAccessDenied as error:
|
||||
return Response({"detail": str(error)}, status=403)
|
||||
call = active_call_for_conversation(conversation)
|
||||
@@ -181,7 +182,7 @@ class InviteResolveView(APIView):
|
||||
try:
|
||||
resolved = resolve_invite(token=token)
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительное или истёкшее приглашение"}, status=404)
|
||||
return Response({"detail": t("calls.invite_invalid")}, status=404)
|
||||
return _token_response(
|
||||
{
|
||||
"call": public_invite_payload(
|
||||
@@ -214,7 +215,7 @@ class CallAccessStateView(_CallAccessView):
|
||||
try:
|
||||
call = call_state_by_access_token(token=_bearer_token(request))
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
|
||||
return Response({"detail": t("calls.token_invalid")}, status=404)
|
||||
return _token_response(
|
||||
{"call": public_call_state_payload(call), "iceServers": ice_servers_payload()}
|
||||
)
|
||||
@@ -225,7 +226,7 @@ class CallAccessAcceptView(_CallAccessView):
|
||||
try:
|
||||
call = accept_call_by_access_token(token=_bearer_token(request))
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
|
||||
return Response({"detail": t("calls.token_invalid")}, status=404)
|
||||
except CallConflict as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
return _token_response({"call": public_call_state_payload(call)})
|
||||
@@ -236,7 +237,7 @@ class CallAccessDeclineView(_CallAccessView):
|
||||
try:
|
||||
call = decline_call_by_access_token(token=_bearer_token(request))
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
|
||||
return Response({"detail": t("calls.token_invalid")}, status=404)
|
||||
except CallConflict as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
return _token_response({"call": public_call_state_payload(call)})
|
||||
@@ -247,5 +248,5 @@ class CallAccessEndView(_CallAccessView):
|
||||
try:
|
||||
call = end_call_by_access_token(token=_bearer_token(request))
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
|
||||
return Response({"detail": t("calls.token_invalid")}, status=404)
|
||||
return _token_response({"call": public_call_state_payload(call)})
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -38,9 +39,9 @@ def require_organization_manage(context: TenantContext, *, operation: str) -> No
|
||||
|
||||
def require_channel_manage(context: TenantContext) -> None:
|
||||
if not has_organization_capability(context, CHANNELS_MANAGE):
|
||||
raise PermissionDenied("Нет прав на изменение канала")
|
||||
raise PermissionDenied(t("channels.no_rights_to_change"))
|
||||
|
||||
|
||||
def require_connections_manage(context: TenantContext) -> None:
|
||||
if not has_organization_capability(context, INTEGRATIONS_MANAGE):
|
||||
raise PermissionDenied("Привязка подключения требует integrations.manage")
|
||||
raise PermissionDenied(t("channels.binding_needs_manage"))
|
||||
@@ -10,6 +10,7 @@ from django.db import transaction
|
||||
|
||||
from chatballs.channels import authorization
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -34,7 +35,7 @@ class ChannelHasReferences(Exception):
|
||||
|
||||
def __init__(self, blockers: list[dict[str, Any]]) -> None:
|
||||
self.blockers = blockers
|
||||
super().__init__("Канал нельзя удалить: есть связанные записи")
|
||||
super().__init__(t("channels.cannot_delete_linked"))
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {"detail": str(self), "blockers": self.blockers}
|
||||
|
||||
@@ -28,6 +28,7 @@ from chatballs.conversations.serializers import message_payload
|
||||
from chatballs.conversations.transports.base import guess_content_type, safe_filename
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.conversations.voice_views import _visible_message
|
||||
from chatballs.i18n import t
|
||||
|
||||
MAX_FILE_BYTES = 20 * 1024 * 1024
|
||||
# Исполняемые и скриптовые типы в чат не отправляем ни в одну сторону.
|
||||
@@ -62,9 +63,9 @@ class MessageAttachmentView(ConversationViewBase):
|
||||
try:
|
||||
message = _visible_message(request, message_id)
|
||||
except (Message.DoesNotExist, Conversation.DoesNotExist):
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
return Response({"detail": t("conversations.message_not_found")}, status=404)
|
||||
if message.kind != MessageKind.FILE or not message.attachment:
|
||||
return Response({"detail": "Файл недоступен"}, status=404)
|
||||
return Response({"detail": t("conversations.file_unavailable")}, status=404)
|
||||
return attachment_response(message, inline="inline" in request.GET)
|
||||
|
||||
|
||||
@@ -80,15 +81,15 @@ class ConversationAttachmentView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
return Response({"detail": t("conversations.closed")}, status=409)
|
||||
connection = conversation.connection
|
||||
if connection is None or not transports.supports_file_send(connection):
|
||||
return Response({"detail": "Файлы недоступны в этом канале"}, status=400)
|
||||
return Response({"detail": t("conversations.files_unavailable_channel")}, status=400)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "Прикрепите файл"}, status=400)
|
||||
return Response({"detail": t("conversations.attach_file")}, status=400)
|
||||
problem = validate_upload(upload)
|
||||
if problem:
|
||||
return Response({"detail": problem}, status=400)
|
||||
@@ -113,7 +114,7 @@ class ConversationAttachmentView(ConversationViewBase):
|
||||
caption=caption,
|
||||
)
|
||||
if not sent:
|
||||
return Response({"detail": "Не удалось отправить файл в канал"}, status=502)
|
||||
return Response({"detail": t("conversations.file_send_failed")}, status=502)
|
||||
message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.OPERATOR,
|
||||
|
||||
@@ -25,6 +25,7 @@ from chatballs.conversations.models import (
|
||||
from chatballs.conversations.selectors import apply_conversation_visibility
|
||||
from chatballs.conversations.serializers import conversation_payload
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.avatars import user_avatar_url
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
@@ -53,10 +54,10 @@ class ConversationPriorityView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
priority = str(request.data.get("priority", "")).strip().upper()
|
||||
if priority not in ConversationPriority.values:
|
||||
return Response({"detail": "Неизвестный приоритет"}, status=400)
|
||||
return Response({"detail": t("conversations.unknown_priority")}, status=400)
|
||||
conversation.priority = priority
|
||||
conversation.save(update_fields=["priority"])
|
||||
self._audit(request, "priority_changed", conversation)
|
||||
@@ -82,10 +83,10 @@ class ConversationContactView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
contact = conversation.contact
|
||||
if contact is None:
|
||||
return Response({"detail": "У диалога нет контакта"}, status=400)
|
||||
return Response({"detail": t("conversations.no_contact")}, status=400)
|
||||
changed: list[str] = []
|
||||
for field, limit in self.LIMITS.items():
|
||||
if field not in request.data:
|
||||
@@ -94,7 +95,7 @@ class ConversationContactView(ConversationViewBase):
|
||||
if len(value) > limit:
|
||||
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
|
||||
if field == "name" and not value:
|
||||
return Response({"detail": "Имя контакта не может быть пустым"}, status=400)
|
||||
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
|
||||
setattr(contact, field, value)
|
||||
changed.append(field)
|
||||
if changed:
|
||||
@@ -118,10 +119,10 @@ class ConversationNoteView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
note = str(request.data.get("note", ""))
|
||||
if len(note) > 4000:
|
||||
return Response({"detail": "Заметка длиннее 4000 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.note_too_long")}, status=400)
|
||||
conversation.note = note
|
||||
conversation.note_author = request.user if note else None
|
||||
conversation.note_updated_at = timezone.now() if note else None
|
||||
@@ -146,7 +147,7 @@ class ConversationLabelsView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
label_ids = request.data.get("labelIds")
|
||||
if not isinstance(label_ids, list) or not all(
|
||||
isinstance(item, int) for item in label_ids
|
||||
@@ -159,7 +160,7 @@ class ConversationLabelsView(ConversationViewBase):
|
||||
)
|
||||
)
|
||||
if len(labels) != len(set(label_ids)):
|
||||
return Response({"detail": "Неизвестная метка"}, status=400)
|
||||
return Response({"detail": t("conversations.unknown_label")}, status=400)
|
||||
conversation.labels.set(labels)
|
||||
return Response(
|
||||
{
|
||||
@@ -182,13 +183,13 @@ class ConversationArchiveView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
archived = request.data.get("archived")
|
||||
if not isinstance(archived, bool):
|
||||
return Response({"detail": "archived must be a boolean"}, status=400)
|
||||
if not archived and not can_administer_access(request.tenant_context.membership):
|
||||
return Response(
|
||||
{"detail": "Восстановить диалог может только администратор"}, status=403
|
||||
{"detail": t("conversations.restore_admin_only")}, status=403
|
||||
)
|
||||
conversation.archived_at = timezone.now() if archived else None
|
||||
conversation.save(update_fields=["archived_at"])
|
||||
@@ -336,9 +337,9 @@ class LabelListView(APIView):
|
||||
name = str(request.data.get("name", "")).strip()
|
||||
color = str(request.data.get("color", "")).strip()
|
||||
if not name or len(name) > 60:
|
||||
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.label_name_length")}, status=400)
|
||||
if len(color) > 20:
|
||||
return Response({"detail": "Некорректный цвет"}, status=400)
|
||||
return Response({"detail": t("conversations.invalid_colour")}, status=400)
|
||||
existing = ConversationLabel.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id, name__iexact=name
|
||||
).first()
|
||||
@@ -365,16 +366,16 @@ class LabelDetailView(APIView):
|
||||
try:
|
||||
label = self._label(request, label_id)
|
||||
except ConversationLabel.DoesNotExist:
|
||||
return Response({"detail": "Метка не найдена"}, status=404)
|
||||
return Response({"detail": t("conversations.label_not_found")}, status=404)
|
||||
if "name" in request.data:
|
||||
name = str(request.data.get("name", "")).strip()
|
||||
if not name or len(name) > 60:
|
||||
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.label_name_length")}, status=400)
|
||||
label.name = name
|
||||
if "color" in request.data:
|
||||
color = str(request.data.get("color", "")).strip()
|
||||
if len(color) > 20:
|
||||
return Response({"detail": "Некорректный цвет"}, status=400)
|
||||
return Response({"detail": t("conversations.invalid_colour")}, status=400)
|
||||
label.color = color
|
||||
label.save()
|
||||
return Response({"label": _label_payload(label)})
|
||||
@@ -383,7 +384,7 @@ class LabelDetailView(APIView):
|
||||
try:
|
||||
label = self._label(request, label_id)
|
||||
except ConversationLabel.DoesNotExist:
|
||||
return Response({"detail": "Метка не найдена"}, status=404)
|
||||
return Response({"detail": t("conversations.label_not_found")}, status=404)
|
||||
label.delete()
|
||||
return Response(status=204)
|
||||
|
||||
@@ -405,13 +406,13 @@ class ReplyTemplateListView(APIView):
|
||||
title = str(request.data.get("title", "")).strip()
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
if not text:
|
||||
return Response({"detail": "Текст шаблона обязателен"}, status=400)
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
if ReplyTemplate.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id, title__iexact=title
|
||||
).exists():
|
||||
return Response({"detail": "Шаблон с таким названием уже есть"}, status=409)
|
||||
return Response({"detail": t("conversations.template_name_taken")}, status=409)
|
||||
template = ReplyTemplate.objects.create(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
title=title,
|
||||
@@ -433,16 +434,16 @@ class ReplyTemplateDetailView(APIView):
|
||||
try:
|
||||
template = self._template(request, template_id)
|
||||
except ReplyTemplate.DoesNotExist:
|
||||
return Response({"detail": "Шаблон не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.template_not_found")}, status=404)
|
||||
if "title" in request.data:
|
||||
title = str(request.data.get("title", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
template.title = title
|
||||
if "text" in request.data:
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": "Текст шаблона обязателен"}, status=400)
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
template.text = text
|
||||
template.save()
|
||||
return Response({"template": _template_payload(template)})
|
||||
@@ -451,6 +452,6 @@ class ReplyTemplateDetailView(APIView):
|
||||
try:
|
||||
template = self._template(request, template_id)
|
||||
except ReplyTemplate.DoesNotExist:
|
||||
return Response({"detail": "Шаблон не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.template_not_found")}, status=404)
|
||||
template.delete()
|
||||
return Response(status=204)
|
||||
@@ -26,10 +26,11 @@ from chatballs.conversations.models import (
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit_catalog import (
|
||||
AUDIT_RESULT_LABELS,
|
||||
audit_action_label,
|
||||
audit_object_label,
|
||||
audit_result_label,
|
||||
)
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.identity.models import AuditEvent
|
||||
@@ -154,7 +155,11 @@ def client_row(contact: Contact) -> dict:
|
||||
return {
|
||||
"id": contact.id,
|
||||
"cid": f"CUS-{contact.id}",
|
||||
"name": contact.name or "Гость",
|
||||
"name": contact.name or t("conversations.guest"),
|
||||
# Признак анонимного посетителя: интерфейс красит его аватар иначе.
|
||||
# Раньше он выводился из самой подписи регуляркой по слову «Гость» —
|
||||
# на другом языке это перестало бы работать.
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact.avatar_url,
|
||||
"email": next(
|
||||
@@ -278,14 +283,18 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
"action": audit_action_label(event.action) or event.action,
|
||||
"object": audit_object_label(event.object_type, event.object_id),
|
||||
"actor": (event.actor.full_name or event.actor.email) if event.actor_id else "Система",
|
||||
"result": AUDIT_RESULT_LABELS.get(event.result, "Неизвестно"),
|
||||
"result": audit_result_label(event.result),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"id": contact.id,
|
||||
"cid": f"CUS-{contact.id}",
|
||||
"name": contact.name or "Гость",
|
||||
"name": contact.name or t("conversations.guest"),
|
||||
# Признак анонимного посетителя: интерфейс красит его аватар иначе.
|
||||
# Раньше он выводился из самой подписи регуляркой по слову «Гость» —
|
||||
# на другом языке это перестало бы работать.
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact.avatar_url,
|
||||
# Поля карточки из чата (описание, компания, город).
|
||||
@@ -325,7 +334,7 @@ def _merges(organization_id: int, contact: Contact) -> list[dict]:
|
||||
{
|
||||
"id": row.id,
|
||||
"sourceId": row.source_id,
|
||||
"sourceName": row.source.name or "Гость",
|
||||
"sourceName": row.source.name or t("conversations.guest"),
|
||||
"sourceCid": f"CUS-{row.source_id}",
|
||||
"reason": row.reason,
|
||||
"actor": _actor_name(row.actor),
|
||||
@@ -354,7 +363,8 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
|
||||
return {
|
||||
"id": other.id,
|
||||
"cid": f"CUS-{other.id}",
|
||||
"name": other.name or "Гость",
|
||||
"name": other.name or t("conversations.guest"),
|
||||
"isGuest": not other.name,
|
||||
"avatarUrl": other.avatar_url,
|
||||
"dialogs": other.conversations.count(),
|
||||
"sources": sorted({identity.connection.provider for identity in identities}),
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
|
||||
@@ -24,7 +25,7 @@ MIN_REASON_LENGTH = 5
|
||||
def _clean_reason(reason: str) -> str:
|
||||
cleaned = (reason or "").strip()
|
||||
if len(cleaned) < MIN_REASON_LENGTH:
|
||||
raise ValidationError("Укажите причину объединения — она попадёт в журнал действий")
|
||||
raise ValidationError(t("sales.merge_reason_required"))
|
||||
return cleaned[:2000]
|
||||
|
||||
|
||||
@@ -33,14 +34,14 @@ def merge_contacts(*, organization, target_id: int, source_id: int, reason: str,
|
||||
"""Перенести идентичности и диалоги source в target."""
|
||||
cleaned = _clean_reason(reason)
|
||||
if target_id == source_id:
|
||||
raise ValidationError("Нельзя объединить контакт с самим собой")
|
||||
raise ValidationError(t("sales.merge_self"))
|
||||
try:
|
||||
target = Contact.objects.select_for_update().get(organization=organization, id=target_id)
|
||||
source = Contact.objects.select_for_update().get(organization=organization, id=source_id)
|
||||
except Contact.DoesNotExist as error:
|
||||
raise ValidationError("Контакт не найден") from error
|
||||
raise ValidationError(t("sales.contact_not_found")) from error
|
||||
if source.merged_into_id is not None or target.merged_into_id is not None:
|
||||
raise ValidationError("Контакт уже объединён с другим — сначала разъедините")
|
||||
raise ValidationError(t("sales.already_merged"))
|
||||
|
||||
identity_ids = list(ConnectionIdentity.objects.filter(contact=source).values_list("id", flat=True))
|
||||
conversation_ids = list(
|
||||
@@ -94,9 +95,9 @@ def revert_merge(*, organization, merge_id: int, reason: str, actor, request=Non
|
||||
try:
|
||||
merge = ContactMerge.objects.select_for_update().get(organization=organization, id=merge_id)
|
||||
except ContactMerge.DoesNotExist as error:
|
||||
raise ValidationError("Объединение не найдено") from error
|
||||
raise ValidationError(t("sales.merge_not_found")) from error
|
||||
if merge.reverted_at is not None:
|
||||
raise ValidationError("Это объединение уже разъединено")
|
||||
raise ValidationError(t("sales.already_unmerged"))
|
||||
|
||||
source = merge.source
|
||||
target = merge.target
|
||||
|
||||
@@ -28,10 +28,12 @@ from chatballs.conversations.models import (
|
||||
Message,
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
SystemEvent,
|
||||
TranscriptStatus,
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.events.models import EventOwnership, InboxEvent
|
||||
from chatballs.i18n import t
|
||||
from chatballs.notifications.models import NotificationAudience, NotificationType
|
||||
from chatballs.notifications.services import notify, notify_management
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -303,6 +305,14 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title=f"Новый диалог · {channel.name}",
|
||||
body=f"{contact.name or 'Гость'} · {integration.provider}: {message_text[:80]}",
|
||||
title_key="notifications.new_dialog",
|
||||
body_key="notifications.new_dialog_body",
|
||||
text_params={
|
||||
"channel": channel.name,
|
||||
"contact": contact.name or t("conversations.guest"),
|
||||
"provider": integration.provider,
|
||||
"preview": message_text[:80],
|
||||
},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -318,6 +328,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
recipient_user=operator,
|
||||
title=f"Новое сообщение · {contact.name or 'Гость'}",
|
||||
body=message_text[:120],
|
||||
title_key="notifications.new_message",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -352,7 +364,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title=f"Нужен оператор · {contact.name or 'Гость'}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body="Голосовое без расшифровки" if is_voice else message_text[:120],
|
||||
body_key="notifications.voice_without_transcript" if is_voice else "",
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -376,7 +391,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
conversation.last_activity_at = timezone.now()
|
||||
conversation.save(update_fields=["control_mode", "expected_responder", "last_activity_at"])
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI недоступен — диалог передан оператору")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_UNAVAILABLE,
|
||||
text="AI недоступен — диалог передан оператору",
|
||||
)
|
||||
fallback = "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится."
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
|
||||
notify(
|
||||
@@ -384,7 +404,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title=f"Нужен оператор · {contact.name or 'Гость'}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body="AI временно недоступен, диалог ждёт ответа",
|
||||
body_key="notifications.ai_unavailable_waiting",
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -395,6 +418,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
type=NotificationType.INTEGRATION_ERROR,
|
||||
title=f"Ошибка AI · {channel.name}",
|
||||
body="AI временно недоступен, диалог передан оператору",
|
||||
title_key="notifications.ai_error",
|
||||
body_key="notifications.ai_unavailable_handed_over",
|
||||
text_params={"channel": channel.name},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -418,12 +444,19 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"])
|
||||
|
||||
if handoff:
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI передал диалог оператору")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_HANDED_OVER,
|
||||
text="AI передал диалог оператору",
|
||||
)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title=f"AI передал диалог · {contact.name or 'Гость'}",
|
||||
title_key="notifications.ai_handed_over",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body=ai_input[:120],
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-09 19:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0020_conversation_last_message_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='system_event',
|
||||
field=models.CharField(blank=True, choices=[('operator_took', 'Оператор перехватил диалог'), ('returned_to_ai', 'Диалог возвращён AI'), ('returned_to_queue', 'Диалог возвращён в очередь'), ('ai_unavailable', 'AI недоступен'), ('ai_handed_over', 'AI передал диалог оператору'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='system_params',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -257,6 +257,33 @@ class MessageAuthor(models.TextChoices):
|
||||
SYSTEM = "SYSTEM", "Система"
|
||||
|
||||
|
||||
class SystemEvent(models.TextChoices):
|
||||
"""Код системного события диалога.
|
||||
|
||||
Текст события раньше писался в ``text`` по-русски и оставался таким
|
||||
навсегда: история — записи, а не подписи, и перевести её задним числом
|
||||
нельзя. Поэтому в базу идёт код, а фразу собирает интерфейс на языке того,
|
||||
кто её читает. ``text`` продолжает заполняться: он остаётся и запасным
|
||||
вариантом для строк, записанных до этого поля, и тем, что видно в базе
|
||||
глазами.
|
||||
"""
|
||||
|
||||
OPERATOR_TOOK = "operator_took", "Оператор перехватил диалог"
|
||||
RETURNED_TO_AI = "returned_to_ai", "Диалог возвращён AI"
|
||||
RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь"
|
||||
AI_UNAVAILABLE = "ai_unavailable", "AI недоступен"
|
||||
AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору"
|
||||
CALL_REQUESTED = "call_requested", "Запрошен звонок"
|
||||
CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение"
|
||||
CALL_DECLINED = "call_declined", "Клиент отклонил приглашение"
|
||||
CALL_CANCELLED = "call_cancelled", "Приглашение отменено"
|
||||
CALL_MISSED = "call_missed", "Звонок пропущен"
|
||||
CALL_EXPIRED = "call_expired", "Приглашение истекло"
|
||||
CALL_STARTED = "call_started", "Звонок начался"
|
||||
CALL_ENDED = "call_ended", "Звонок завершён"
|
||||
CALL_FAILED = "call_failed", "Звонок не состоялся"
|
||||
|
||||
|
||||
class MessageKind(models.TextChoices):
|
||||
TEXT = "", "Текст"
|
||||
CONTACT_REQUEST = "contact_request", "Запрос контакта"
|
||||
@@ -300,6 +327,11 @@ class Message(TenantRelationModel):
|
||||
# телефона), полученный контакт. Пустая строка = текст.
|
||||
kind = models.CharField(max_length=32, choices=MessageKind.choices, default=MessageKind.TEXT, blank=True)
|
||||
text = models.TextField(blank=True)
|
||||
# Системное событие: код и его параметры (имя оператора, длительность).
|
||||
# Пустой код — обычное сообщение либо системная запись, сделанная до
|
||||
# появления поля; такие показываются по сохранённому тексту.
|
||||
system_event = models.CharField(max_length=32, choices=SystemEvent.choices, blank=True, default="")
|
||||
system_params = models.JSONField(default=dict, blank=True)
|
||||
# Санитизированный HTML входящего email. Остальные транспорты и исходящие
|
||||
# ответы используют plain text.
|
||||
content_html = models.TextField(blank=True)
|
||||
|
||||
@@ -8,6 +8,7 @@ from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
|
||||
from chatballs.conversations.models import Contact
|
||||
from chatballs.conversations.stats import sales_overview_stats
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
|
||||
@@ -41,14 +42,14 @@ class ClientDetailView(ConversationViewBase):
|
||||
try:
|
||||
return Response({"client": client_detail(self._org(request).id, contact_id)})
|
||||
except Contact.DoesNotExist:
|
||||
return Response({"detail": "Клиент не найден"}, status=404)
|
||||
return Response({"detail": t("sales.client_not_found")}, status=404)
|
||||
|
||||
def patch(self, request: Request, contact_id: int) -> Response:
|
||||
organization = self._org(request)
|
||||
try:
|
||||
contact = Contact.objects.get(id=contact_id, organization=organization)
|
||||
except Contact.DoesNotExist:
|
||||
return Response({"detail": "Клиент не найден"}, status=404)
|
||||
return Response({"detail": t("sales.client_not_found")}, status=404)
|
||||
changed: list[str] = []
|
||||
for field, limit in self.LIMITS.items():
|
||||
if field not in request.data:
|
||||
@@ -57,7 +58,7 @@ class ClientDetailView(ConversationViewBase):
|
||||
if len(value) > limit:
|
||||
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
|
||||
if field == "name" and not value:
|
||||
return Response({"detail": "Имя контакта не может быть пустым"}, status=400)
|
||||
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
|
||||
setattr(contact, field, value)
|
||||
changed.append(field)
|
||||
if changed:
|
||||
@@ -86,7 +87,7 @@ class ClientMergeView(ConversationViewBase):
|
||||
def _owner_only(self, request: Request) -> Response | None:
|
||||
membership = request.tenant_context.membership
|
||||
if membership is None or membership.role != EmployeeRole.OWNER:
|
||||
return Response({"detail": "Объединять контакты может только владелец"}, status=403)
|
||||
return Response({"detail": t("sales.merge_owner_only")}, status=403)
|
||||
return None
|
||||
|
||||
def post(self, request: Request, contact_id: int) -> Response:
|
||||
|
||||
@@ -7,11 +7,23 @@ from chatballs.conversations.models import (
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
|
||||
def _system_text(message: Message) -> str:
|
||||
params = message.system_params or {}
|
||||
# Вид звонка приходит кодом (AUDIO/VIDEO): слово для него — тоже в каталоге.
|
||||
if params.get("kind"):
|
||||
params = {**params, "kind": t(f"calls.kind_{str(params['kind']).lower()}")}
|
||||
rendered = t(f"conversations.system.{message.system_event}", **params)
|
||||
# Ключа нет в каталоге — t вернул сам ключ; тогда честнее показать то, что
|
||||
# записано, чем служебный код.
|
||||
return message.text if rendered.startswith("conversations.system.") else rendered
|
||||
|
||||
|
||||
def message_payload(message: Message) -> dict[str, object]:
|
||||
payload = {
|
||||
"id": message.id,
|
||||
@@ -21,7 +33,14 @@ def message_payload(message: Message) -> dict[str, object]:
|
||||
"authorName": (message.author_user.full_name or message.author_user.email) if message.author_user_id and message.author_user else "",
|
||||
"authorAvatarUrl": user_avatar_url_in(message.author_user, message.organization_id) if message.author_user_id and message.author_user else None,
|
||||
"kind": message.kind,
|
||||
"text": message.text,
|
||||
# Системное событие собирается по коду на языке запроса: историю
|
||||
# диалога читают оба — и русскоязычный оператор, и англоязычный, — а
|
||||
# записана она один раз. Код без перевода и записи, сделанные до его
|
||||
# появления, приходят сохранённым текстом.
|
||||
"text": _system_text(message) if message.system_event else message.text,
|
||||
# Код нужен интерфейсу ещё и для тона строки: передача — предупреждение,
|
||||
# взятие — акцент. Раньше тон угадывался регуляркой по русским словам.
|
||||
"systemEvent": message.system_event,
|
||||
"contentHtml": message.content_html,
|
||||
"createdAt": message.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ from chatballs.conversations.models import (
|
||||
Message,
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -36,7 +38,7 @@ def _require_open(conversation: Conversation) -> None:
|
||||
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
|
||||
raise ClaimError("Диалог закрыт")
|
||||
raise ClaimError(t("conversations.closed"))
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +78,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
if operator is None or conversation.organization_id != context.organization_id:
|
||||
|
||||
raise ClaimError("Диалог недоступен")
|
||||
raise ClaimError(t("conversations.unavailable"))
|
||||
|
||||
_require_open(conversation)
|
||||
|
||||
@@ -98,7 +100,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
):
|
||||
|
||||
raise ClaimError("Диалог уже ведёт другой оператор")
|
||||
raise ClaimError(t("calls.conversation_taken"))
|
||||
|
||||
conversation.control_mode = ControlMode.HUMAN
|
||||
|
||||
@@ -140,7 +142,7 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
|
||||
|
||||
if agent is None or not agent.is_active:
|
||||
|
||||
raise ClaimError("У канала нет активного AI-агента")
|
||||
raise ClaimError(t("channels.no_active_agent"))
|
||||
|
||||
conversation.control_mode = ControlMode.AI
|
||||
|
||||
@@ -150,7 +152,12 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён AI")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.RETURNED_TO_AI,
|
||||
text="Диалог возвращён AI",
|
||||
)
|
||||
|
||||
return conversation
|
||||
|
||||
@@ -180,7 +187,12 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён в очередь")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.RETURNED_TO_QUEUE,
|
||||
text="Диалог возвращён в очередь",
|
||||
)
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from dataclasses import dataclass, field
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.outbound import ensure_downloadable
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
@@ -104,7 +105,7 @@ def download_bytes(
|
||||
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
|
||||
data = response.read(max_bytes + 1)
|
||||
if len(data) > max_bytes:
|
||||
raise ValueError("Файл больше допустимого размера")
|
||||
raise ValueError(t("conversations.file_too_large"))
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ from chatballs.conversations.services import (
|
||||
return_to_queue,
|
||||
)
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.identity.policy import ResourceScope, authorize, can_administer_access
|
||||
@@ -58,7 +59,7 @@ class ConversationListView(ConversationViewBase):
|
||||
# «Удалённые» (архив) скрыты; просмотр архива — только администратор.
|
||||
if params.get("archived") == "1":
|
||||
if not can_administer_access(request.tenant_context.membership):
|
||||
return Response({"detail": "Архив доступен администраторам"}, status=403)
|
||||
return Response({"detail": t("conversations.archive_admin_only")}, status=403)
|
||||
items = items.filter(archived_at__isnull=False)
|
||||
else:
|
||||
items = items.filter(archived_at__isnull=True)
|
||||
@@ -145,7 +146,7 @@ class ConversationDetailView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
# Открытие диалога = прочтение: двигаем персональную отметку до последнего
|
||||
# сообщения (detail поллится каждые 3 с — пишем только при продвижении).
|
||||
last_id = conversation.messages.order_by("-id").values_list("id", flat=True).first() or 0
|
||||
@@ -174,7 +175,7 @@ class ConversationClaimView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = claim_conversation(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -200,7 +201,7 @@ class ConversationReleaseView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = release_to_ai(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -226,7 +227,7 @@ class ConversationReturnQueueView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = return_to_queue(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -264,7 +265,7 @@ def claim_for_reply(view: ConversationViewBase, request: Request, conversation:
|
||||
ResourceScope(conversation.organization_id),
|
||||
)
|
||||
if conversation.assigned_operator_id != request.user.id and not manager_override:
|
||||
return Response({"detail": "Диалог ведёт другой оператор"}, status=409)
|
||||
return Response({"detail": t("conversations.handled_by_other")}, status=409)
|
||||
return conversation
|
||||
|
||||
|
||||
@@ -284,7 +285,7 @@ class ConversationMessageView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
params = request.query_params
|
||||
limit = window_size(params, default=MESSAGE_WINDOW_SIZE)
|
||||
messages = conversation_messages(conversation)
|
||||
@@ -313,12 +314,12 @@ class ConversationMessageView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": "Пустое сообщение"}, status=400)
|
||||
return Response({"detail": t("ai.empty_message")}, status=400)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
return Response({"detail": t("conversations.closed")}, status=409)
|
||||
claimed = claim_for_reply(self, request, conversation)
|
||||
if isinstance(claimed, Response):
|
||||
return claimed
|
||||
@@ -339,13 +340,13 @@ class ConversationRequestContactView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
if not conversation.contact_id or not conversation.connection_id:
|
||||
return Response({"detail": "У диалога нет канала для запроса контакта"}, status=409)
|
||||
return Response({"detail": t("conversations.no_channel_for_contact")}, status=409)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
return Response({"detail": t("conversations.closed")}, status=409)
|
||||
if conversation.contact.phone:
|
||||
return Response({"detail": "Контакт уже получен"}, status=409)
|
||||
return Response({"detail": t("conversations.contact_already_received")}, status=409)
|
||||
try:
|
||||
message = request_contact(
|
||||
context=request.tenant_context,
|
||||
@@ -364,7 +365,7 @@ class ConversationCloseView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = close_conversation(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -390,7 +391,7 @@ class ConversationSpamView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = mark_conversation_as_spam(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -418,7 +419,7 @@ class ConversationGroupView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
group_id = request.data.get("groupId")
|
||||
group = None
|
||||
if group_id is not None:
|
||||
@@ -426,7 +427,7 @@ class ConversationGroupView(ConversationViewBase):
|
||||
organization_id=conversation.organization_id, id=group_id
|
||||
).first()
|
||||
if group is None:
|
||||
return Response({"detail": "Группа не найдена"}, status=400)
|
||||
return Response({"detail": t("admin.group_not_found")}, status=400)
|
||||
conversation.group = group
|
||||
conversation.save(update_fields=["group"])
|
||||
self._audit(request, "group_changed", conversation)
|
||||
@@ -448,7 +449,7 @@ class ConversationAssigneeView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
user_id = request.data.get("userId")
|
||||
assignee = None
|
||||
if user_id is not None:
|
||||
@@ -462,7 +463,7 @@ class ConversationAssigneeView(ConversationViewBase):
|
||||
.first()
|
||||
)
|
||||
if membership is None:
|
||||
return Response({"detail": "Сотрудник не найден"}, status=400)
|
||||
return Response({"detail": t("admin.employee_not_found")}, status=400)
|
||||
assignee = membership.user
|
||||
conversation.assigned_operator = assignee
|
||||
conversation.save(update_fields=["assigned_operator"])
|
||||
|
||||
@@ -29,6 +29,7 @@ from chatballs.conversations.models import (
|
||||
from chatballs.conversations.selectors import conversation_for_context
|
||||
from chatballs.conversations.serializers import message_payload
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.features import voice_messages_allowed
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
|
||||
@@ -53,9 +54,9 @@ class MessageAudioView(ConversationViewBase):
|
||||
try:
|
||||
message = _visible_message(request, message_id)
|
||||
except (Message.DoesNotExist, Conversation.DoesNotExist):
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
return Response({"detail": t("conversations.message_not_found")}, status=404)
|
||||
if not message.audio:
|
||||
return Response({"detail": "Аудио недоступно"}, status=404)
|
||||
return Response({"detail": t("conversations.audio_unavailable")}, status=404)
|
||||
return FileResponse(
|
||||
message.audio.open("rb"),
|
||||
content_type=message.audio_content_type or "application/octet-stream",
|
||||
@@ -86,9 +87,9 @@ class MessageTranscribeView(ConversationViewBase):
|
||||
try:
|
||||
message = _visible_message(request, message_id)
|
||||
except (Message.DoesNotExist, Conversation.DoesNotExist):
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
return Response({"detail": t("conversations.message_not_found")}, status=404)
|
||||
if message.kind != MessageKind.VOICE or not message.audio:
|
||||
return Response({"detail": "Это не голосовое сообщение"}, status=400)
|
||||
return Response({"detail": t("conversations.not_a_voice_message")}, status=400)
|
||||
if message.transcript_status == TranscriptStatus.READY:
|
||||
return Response({"message": message_payload(message)})
|
||||
try:
|
||||
@@ -97,7 +98,7 @@ class MessageTranscribeView(ConversationViewBase):
|
||||
mark_transcription_failed(message)
|
||||
return Response({"detail": str(error)}, status=502)
|
||||
if job is None:
|
||||
return Response({"detail": "Аудио недоступно"}, status=404)
|
||||
return Response({"detail": t("conversations.audio_unavailable")}, status=404)
|
||||
|
||||
try:
|
||||
transcript = run_transcription(job)
|
||||
@@ -110,7 +111,7 @@ class MessageTranscribeView(ConversationViewBase):
|
||||
store_transcription(message, transcript)
|
||||
payload = message_payload(message)
|
||||
if not transcript:
|
||||
return Response({"detail": "Провайдер вернул пустую расшифровку"}, status=502)
|
||||
return Response({"detail": t("ai.empty_transcript")}, status=502)
|
||||
return Response({"message": payload})
|
||||
|
||||
|
||||
@@ -129,24 +130,24 @@ class ConversationVoiceView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
return Response({"detail": t("conversations.closed")}, status=409)
|
||||
connection = conversation.connection
|
||||
if connection is None or not transports.supports_voice_send(connection):
|
||||
return Response(
|
||||
{"detail": "Голосовые сообщения недоступны в этом канале"}, status=400
|
||||
{"detail": t("conversations.voice_unavailable_channel")}, status=400
|
||||
)
|
||||
if not voice_messages_allowed(connection):
|
||||
return Response({"detail": "Голосовые отключены для этой точки входа"}, status=400)
|
||||
return Response({"detail": t("conversations.voice_off_entry_point")}, status=400)
|
||||
upload = request.FILES.get("audio")
|
||||
if upload is None:
|
||||
return Response({"detail": "Прикрепите аудио"}, status=400)
|
||||
return Response({"detail": t("conversations.attach_audio")}, status=400)
|
||||
if upload.size > MAX_VOICE_BYTES:
|
||||
return Response({"detail": "Аудио больше 10 МБ"}, status=400)
|
||||
return Response({"detail": t("conversations.audio_too_large")}, status=400)
|
||||
content_type = (upload.content_type or "audio/ogg").split(";")[0]
|
||||
if content_type not in ALLOWED_AUDIO_TYPES:
|
||||
return Response({"detail": "Неподдерживаемый формат аудио"}, status=400)
|
||||
return Response({"detail": t("conversations.audio_format_unsupported")}, status=400)
|
||||
try:
|
||||
duration = max(0, int(request.data.get("duration", 0)))
|
||||
except (TypeError, ValueError):
|
||||
@@ -169,7 +170,7 @@ class ConversationVoiceView(ConversationViewBase):
|
||||
)
|
||||
if not sent:
|
||||
return Response(
|
||||
{"detail": "Не удалось отправить голосовое в канал"}, status=502
|
||||
{"detail": t("conversations.voice_send_failed")}, status=502
|
||||
)
|
||||
message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Языки интерфейса и правило, по которому запрос выбирает свой.
|
||||
|
||||
Язык не выводится из окружения и не задаётся переменной: его выбирает человек.
|
||||
Владелец задаёт язык организации — на нём рабочее место открывается всем, кто
|
||||
в неё вошёл; отдельный сотрудник может переопределить его в профиле, и тогда
|
||||
организация ему не указ. До входа организации ещё нет, и язык берётся из
|
||||
настроек установки: на нём показываются логин, сброс пароля и мастер первого
|
||||
запуска.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
RUSSIAN: Final = "ru"
|
||||
ENGLISH: Final = "en"
|
||||
|
||||
# Порядок важен: первый язык — тот, на котором написан исходный каталог и на
|
||||
# который приходится фолбэк, когда перевода нет.
|
||||
LANGUAGES: Final[tuple[tuple[str, str], ...]] = (
|
||||
(RUSSIAN, "Русский"),
|
||||
(ENGLISH, "English"),
|
||||
)
|
||||
|
||||
LANGUAGE_CODES: Final[tuple[str, ...]] = tuple(code for code, _ in LANGUAGES)
|
||||
DEFAULT_LANGUAGE: Final = RUSSIAN
|
||||
|
||||
# Пустая строка в профиле сотрудника означает «как в организации», а в
|
||||
# организации — «как в установке». Это не язык, а отсутствие выбора, поэтому
|
||||
# отдельного значения в LANGUAGES у неё нет.
|
||||
INHERIT: Final = ""
|
||||
|
||||
|
||||
def normalize_language(value: object) -> str:
|
||||
"""Привести код к поддерживаемому языку или вернуть INHERIT.
|
||||
|
||||
Принимает и «ru-RU», и «RU», и «ru_ru»: браузер, операционная система и
|
||||
заголовок Accept-Language пишут регион по-разному, а выбор один и тот же.
|
||||
Неизвестный язык — не ошибка: это просьба, которую нечем удовлетворить, и
|
||||
она сводится к «как выше по цепочке».
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
return INHERIT
|
||||
code = value.strip().replace("_", "-").lower()
|
||||
if not code:
|
||||
return INHERIT
|
||||
base = code.partition("-")[0]
|
||||
return base if base in LANGUAGE_CODES else INHERIT
|
||||
|
||||
|
||||
def parse_accept_language(header: str | None) -> str:
|
||||
"""Первый поддерживаемый язык из Accept-Language, иначе INHERIT.
|
||||
|
||||
Заголовок разбирается по убыванию q-веса: «en;q=0.9, ru» означает русский,
|
||||
хотя английский стоит первым.
|
||||
"""
|
||||
|
||||
if not header:
|
||||
return INHERIT
|
||||
weighted: list[tuple[float, int, str]] = []
|
||||
for position, part in enumerate(header.split(",")):
|
||||
token, _, params = part.strip().partition(";")
|
||||
code = normalize_language(token)
|
||||
if not code:
|
||||
continue
|
||||
quality = 1.0
|
||||
for param in params.split(";"):
|
||||
key, _, raw = param.strip().partition("=")
|
||||
if key.strip() == "q":
|
||||
try:
|
||||
quality = float(raw)
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
weighted.append((-quality, position, code))
|
||||
if not weighted:
|
||||
return INHERIT
|
||||
weighted.sort()
|
||||
return weighted[0][2]
|
||||
|
||||
|
||||
def first_chosen(*candidates: str) -> str:
|
||||
"""Первый уровень цепочки, который язык действительно выбрал.
|
||||
|
||||
Возвращает INHERIT, если не выбрал ни один: вызывающий по этому признаку
|
||||
решает, спрашивать ли следующий уровень. Нужно там, где следующий уровень
|
||||
стоит запроса к базе, — незачем его читать, если выбор уже сделан.
|
||||
"""
|
||||
|
||||
for candidate in candidates:
|
||||
code = normalize_language(candidate)
|
||||
if code:
|
||||
return code
|
||||
return INHERIT
|
||||
|
||||
|
||||
def resolve_language(
|
||||
*,
|
||||
user_language: str = INHERIT,
|
||||
organization_language: str = INHERIT,
|
||||
instance_language: str = INHERIT,
|
||||
accept_language: str | None = None,
|
||||
) -> str:
|
||||
"""Язык одного запроса: от личного выбора к общему умолчанию.
|
||||
|
||||
Порядок фиксированный — профиль, организация, установка, браузер. Браузер
|
||||
стоит последним и работает только до входа: после входа язык человека уже
|
||||
известен, и подстраиваться под ноутбук, с которого он сегодня зашёл, не
|
||||
нужно.
|
||||
"""
|
||||
|
||||
chosen = first_chosen(user_language, organization_language, instance_language)
|
||||
return chosen or parse_accept_language(accept_language) or DEFAULT_LANGUAGE
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Сборка каталога: русский — источник истины, английский обязан его повторить.
|
||||
|
||||
Ключ пишется как «модуль.смысл» и не повторяет саму фразу: строку правят чаще,
|
||||
чем переименовывают, и ключ вида «identity.email_taken» переживает
|
||||
переформулировку, а «identity.email_already_used» — нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chatballs.i18n.messages import en, ru
|
||||
|
||||
CATALOG: dict[str, dict[str, object]] = {"ru": ru.MESSAGES, "en": en.MESSAGES}
|
||||
|
||||
__all__ = ["CATALOG"]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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": "Диалог недоступен",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Проверки языка интерфейса.
|
||||
|
||||
Главная из них — совпадение наборов ключей в каталогах. Пропущенный перевод не
|
||||
роняет запрос (строка выходит по-русски), и именно поэтому его легко не
|
||||
заметить: без теста английский каталог отстаёт молча.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from chatballs.i18n import normalize_language, parse_accept_language, resolve_language, t
|
||||
from chatballs.i18n.catalog import _plural_category
|
||||
from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGE_CODES
|
||||
from chatballs.i18n.messages import CATALOG
|
||||
|
||||
|
||||
class CatalogTests(SimpleTestCase):
|
||||
def test_every_language_has_the_same_keys(self) -> None:
|
||||
russian = set(CATALOG["ru"])
|
||||
for code, table in CATALOG.items():
|
||||
if code == DEFAULT_LANGUAGE:
|
||||
continue
|
||||
self.assertEqual(
|
||||
russian - set(table), set(), f"нет перевода на {code}"
|
||||
)
|
||||
self.assertEqual(
|
||||
set(table) - russian, set(), f"лишние ключи в {code}"
|
||||
)
|
||||
|
||||
def test_placeholders_match_between_languages(self) -> None:
|
||||
"""Параметры фразы обязаны совпадать: иначе подстановка молча потеряется."""
|
||||
|
||||
placeholders = re.compile(r"\{(\w+)\}")
|
||||
|
||||
def names(message: object) -> set[str]:
|
||||
if isinstance(message, dict):
|
||||
return {n for form in message.values() for n in placeholders.findall(form)}
|
||||
return set(placeholders.findall(str(message)))
|
||||
|
||||
for key, russian in CATALOG["ru"].items():
|
||||
for code, table in CATALOG.items():
|
||||
if code == DEFAULT_LANGUAGE:
|
||||
continue
|
||||
self.assertEqual(
|
||||
names(russian), names(table[key]), f"{key}: расходятся параметры в {code}"
|
||||
)
|
||||
|
||||
def test_unknown_key_returns_itself(self) -> None:
|
||||
self.assertEqual(t("нет.такого.ключа"), "нет.такого.ключа")
|
||||
|
||||
def test_plural_rule_follows_the_language(self) -> None:
|
||||
"""Форма выбирается по правилам CLDR, а не по «1 — единственное».
|
||||
|
||||
Строк с числом в каталоге бэкенда сейчас нет, и проверять нечего, кроме
|
||||
самого правила: русскому нужны четыре формы, английскому — две, и
|
||||
ошибка здесь всплыла бы на первой же фразе со счётчиком.
|
||||
"""
|
||||
|
||||
self.assertEqual([_plural_category("ru", n) for n in (1, 2, 5, 11, 21)],
|
||||
["one", "few", "many", "many", "one"])
|
||||
self.assertEqual([_plural_category("en", n) for n in (1, 2)], ["one", "other"])
|
||||
|
||||
|
||||
class LanguageResolutionTests(SimpleTestCase):
|
||||
def test_normalize_accepts_region_and_case(self) -> None:
|
||||
self.assertEqual(normalize_language("ru-RU"), "ru")
|
||||
self.assertEqual(normalize_language("EN"), "en")
|
||||
self.assertEqual(normalize_language("ru_ru"), "ru")
|
||||
|
||||
def test_unknown_language_is_not_a_choice(self) -> None:
|
||||
# Не ошибка, а «выбора нет»: запрос сводится к языку выше по цепочке.
|
||||
self.assertEqual(normalize_language("de"), "")
|
||||
self.assertEqual(normalize_language(None), "")
|
||||
|
||||
def test_accept_language_respects_quality(self) -> None:
|
||||
self.assertEqual(parse_accept_language("en;q=0.9, ru"), "ru")
|
||||
self.assertEqual(parse_accept_language("en-GB,en;q=0.9"), "en")
|
||||
self.assertEqual(parse_accept_language("de,fr;q=0.5"), "")
|
||||
|
||||
def test_personal_choice_beats_organization(self) -> None:
|
||||
self.assertEqual(
|
||||
resolve_language(user_language="en", organization_language="ru"), "en"
|
||||
)
|
||||
|
||||
def test_organization_beats_installation(self) -> None:
|
||||
self.assertEqual(
|
||||
resolve_language(organization_language="en", instance_language="ru"), "en"
|
||||
)
|
||||
|
||||
def test_browser_is_the_last_resort(self) -> None:
|
||||
self.assertEqual(resolve_language(accept_language="en-US"), "en")
|
||||
self.assertEqual(resolve_language(), DEFAULT_LANGUAGE)
|
||||
|
||||
|
||||
class FrontendListTests(SimpleTestCase):
|
||||
"""Список языков продублирован во фронтенде — он обязан совпадать.
|
||||
|
||||
Виджет открывается у клиента до любого запроса к API и знает свой язык сам,
|
||||
поэтому список там свой. Расхождение означало бы язык, который интерфейс
|
||||
предлагает, а сервер не принимает.
|
||||
"""
|
||||
|
||||
def test_frontend_language_list_matches(self) -> None:
|
||||
source = (
|
||||
pathlib.Path(__file__).resolve().parents[4]
|
||||
/ "packages/shared/src/i18n/index.ts"
|
||||
)
|
||||
if not source.exists():
|
||||
# В образе бэкенда фронтенда нет: проверка имеет смысл только на
|
||||
# полном чекауте, где и правят оба списка.
|
||||
self.skipTest("фронтенд не смонтирован")
|
||||
codes = re.findall(r'\{ code: "(\w+)", label:', source.read_text(encoding="utf-8"))
|
||||
self.assertEqual(tuple(codes), LANGUAGE_CODES)
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls")
|
||||
class MiddlewareTests(TestCase):
|
||||
def test_response_declares_the_language(self) -> None:
|
||||
"""Ответ помечен языком и Vary: один URL отдаёт разный текст."""
|
||||
|
||||
response = self.client.get("/api/v1/auth/session/", HTTP_ACCEPT_LANGUAGE="en")
|
||||
self.assertEqual(response["Content-Language"], "en")
|
||||
self.assertIn("Accept-Language", response["Vary"])
|
||||
@@ -4,12 +4,13 @@ from zoneinfo import available_timezones
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from chatballs.i18n.languages import LANGUAGES
|
||||
from chatballs.identity.audit_catalog import (
|
||||
AUDIT_RESULT_LABELS,
|
||||
audit_action_label,
|
||||
audit_category,
|
||||
audit_category_label,
|
||||
audit_object_label,
|
||||
audit_result_label,
|
||||
)
|
||||
from chatballs.identity.models import AuditEvent, Organization
|
||||
|
||||
@@ -42,6 +43,9 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
|
||||
"name": organization.name,
|
||||
"timezone": organization.timezone,
|
||||
"currency": organization.currency,
|
||||
# Пустая строка доезжает до интерфейса как есть: там это отдельный
|
||||
# пункт «Как в установке», а не отсутствие значения.
|
||||
"language": organization.language,
|
||||
"updatedAt": organization_updated_at(organization),
|
||||
"logoUrl": (
|
||||
reverse(
|
||||
@@ -58,6 +62,17 @@ def administration_timezones() -> list[str]:
|
||||
return sorted(available_timezones())
|
||||
|
||||
|
||||
def administration_languages() -> list[dict[str, str]]:
|
||||
"""Языки для выпадающего списка в «Организации».
|
||||
|
||||
Подпись — на самом языке («Русский», «English»), а не переведённая:
|
||||
человек ищет в списке свой язык, и «Русский» он узнает, даже когда
|
||||
интерфейс сейчас английский.
|
||||
"""
|
||||
|
||||
return [{"code": code, "label": label} for code, label in LANGUAGES]
|
||||
|
||||
|
||||
def audit_event_payload(event: AuditEvent) -> dict[str, object]:
|
||||
"""Событие журнала. Отдаём и код действия, и подпись: подписи может не быть
|
||||
(тогда интерфейс показывает код), а код нужен для поиска в любом случае."""
|
||||
@@ -78,7 +93,7 @@ def audit_event_payload(event: AuditEvent) -> dict[str, object]:
|
||||
"objectType": event.object_type,
|
||||
"objectId": event.object_id,
|
||||
"result": event.result,
|
||||
"resultLabel": AUDIT_RESULT_LABELS.get(event.result, "Неизвестно"),
|
||||
"resultLabel": audit_result_label(event.result),
|
||||
"sourceIp": event.source_ip or "",
|
||||
"correlationId": event.correlation_id,
|
||||
"details": event.payload or {},
|
||||
|
||||
@@ -9,6 +9,8 @@ from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import normalize_language
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.storage import adjust_storage_usage
|
||||
@@ -26,27 +28,36 @@ class OrganizationSettingsInput:
|
||||
name: str
|
||||
timezone: str
|
||||
currency: str
|
||||
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
|
||||
# и владелец, который его не трогал, не должен получить жёсткий русский
|
||||
# после того, как язык установки сменили.
|
||||
language: str = ""
|
||||
|
||||
|
||||
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
name = data.name.strip()
|
||||
timezone = data.timezone.strip()
|
||||
currency = data.currency.strip().upper()
|
||||
language = normalize_language(data.language)
|
||||
if data.language.strip() and not language:
|
||||
raise ValidationError({"language": t("settings.language_unsupported")})
|
||||
if not name:
|
||||
raise ValidationError({"name": "Укажите название организации"})
|
||||
raise ValidationError({"name": t("admin.organization_name_required")})
|
||||
if len(name) > 255:
|
||||
raise ValidationError({"name": "Название не должно превышать 255 символов"})
|
||||
raise ValidationError({"name": t("admin.name_too_long")})
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except (ValueError, ZoneInfoNotFoundError) as error:
|
||||
raise ValidationError(
|
||||
{"timezone": "Укажите корректный часовой пояс"}
|
||||
{"timezone": t("admin.invalid_timezone")}
|
||||
) from error
|
||||
if currency != "RUB":
|
||||
raise ValidationError(
|
||||
{"currency": "Поддерживается только российский рубль (RUB)"}
|
||||
{"currency": t("admin.currency_rub_only")}
|
||||
)
|
||||
return OrganizationSettingsInput(name=name, timezone=timezone, currency=currency)
|
||||
return OrganizationSettingsInput(
|
||||
name=name, timezone=timezone, currency=currency, language=language
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -62,7 +73,8 @@ def update_organization_settings(
|
||||
organization.name = clean.name
|
||||
organization.timezone = clean.timezone
|
||||
organization.currency = clean.currency
|
||||
organization.save(update_fields=["name", "timezone", "currency"])
|
||||
organization.language = clean.language
|
||||
organization.save(update_fields=["name", "timezone", "currency", "language"])
|
||||
return organization
|
||||
|
||||
|
||||
@@ -84,7 +96,7 @@ def replace_organization_logo(
|
||||
) -> Organization:
|
||||
data = upload.read()
|
||||
if not data:
|
||||
raise ValidationError({"file": "Выберите файл логотипа"})
|
||||
raise ValidationError({"file": t("admin.choose_logo_file")})
|
||||
if len(data) > MAX_LOGO_BYTES:
|
||||
raise ValidationError({"file": "Размер логотипа не должен превышать 2 МБ"})
|
||||
detected = _image_type(data)
|
||||
|
||||
@@ -13,7 +13,9 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.administration_payloads import (
|
||||
administration_languages,
|
||||
administration_timezones,
|
||||
audit_event_payload,
|
||||
organization_settings_payload,
|
||||
@@ -29,6 +31,7 @@ from chatballs.identity.audit_catalog import (
|
||||
AUDIT_CATEGORY_ALIASES,
|
||||
AUDIT_RESULT_LABELS,
|
||||
audit_categories,
|
||||
audit_result_label,
|
||||
)
|
||||
from chatballs.identity.models import AuditEvent
|
||||
|
||||
@@ -58,6 +61,7 @@ class OrganizationSettingsView(APIView):
|
||||
request.tenant_context.organization
|
||||
),
|
||||
"timezones": administration_timezones(),
|
||||
"languages": administration_languages(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -71,6 +75,7 @@ class OrganizationSettingsView(APIView):
|
||||
name=str(body.get("name", organization.name)),
|
||||
timezone=str(body.get("timezone", organization.timezone)),
|
||||
currency=str(body.get("currency", organization.currency)),
|
||||
language=str(body.get("language", organization.language)),
|
||||
),
|
||||
)
|
||||
except ValidationError as error:
|
||||
@@ -101,7 +106,7 @@ class OrganizationLogoView(APIView):
|
||||
def get(self, request: Request):
|
||||
organization = request.tenant_context.organization
|
||||
if not organization.logo:
|
||||
return Response({"detail": "Логотип не загружен"}, status=404)
|
||||
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
|
||||
return FileResponse(
|
||||
organization.logo.open("rb"),
|
||||
content_type=organization.logo_content_type or "application/octet-stream",
|
||||
@@ -111,7 +116,7 @@ class OrganizationLogoView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "Выберите файл логотипа"}, status=400)
|
||||
return Response({"detail": t("admin.choose_logo_file")}, status=400)
|
||||
try:
|
||||
organization = replace_organization_logo(
|
||||
context=request.tenant_context,
|
||||
@@ -244,8 +249,8 @@ class AuditListView(APIView):
|
||||
"filters": {
|
||||
"categories": audit_categories(),
|
||||
"results": [
|
||||
{"value": key, "label": label}
|
||||
for key, label in AUDIT_RESULT_LABELS.items()
|
||||
{"value": key, "label": audit_result_label(key)}
|
||||
for key in AUDIT_RESULT_LABELS
|
||||
],
|
||||
"actors": _audit_actors(base),
|
||||
},
|
||||
|
||||
@@ -14,22 +14,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
# Разделы журнала: по ним фильтр и группировка. Ключ — префикс кода действия
|
||||
# до первой точки.
|
||||
AUDIT_CATEGORY_LABELS: dict[str, str] = {
|
||||
"identity": "Доступ и сотрудники",
|
||||
"access_assignment": "Доступ и сотрудники",
|
||||
"administration": "Настройки",
|
||||
"organization": "Организация",
|
||||
"integrations": "Интеграции",
|
||||
"channels": "Каналы",
|
||||
"ai": "AI и знания",
|
||||
"conversations": "Диалоги",
|
||||
"conversation": "Диалоги",
|
||||
"contacts": "Контакты",
|
||||
"calls": "Звонки",
|
||||
"support_portal": "Порталы",
|
||||
"demo": "Демо-данные",
|
||||
"identity": "audit.category_identity",
|
||||
"access_assignment": "audit.category_access_assignment",
|
||||
"administration": "audit.category_administration",
|
||||
"organization": "audit.category_organization",
|
||||
"integrations": "audit.category_integrations",
|
||||
"channels": "audit.category_channels",
|
||||
"ai": "audit.category_ai",
|
||||
"conversations": "audit.category_conversations",
|
||||
"conversation": "audit.category_conversation",
|
||||
"contacts": "audit.category_contacts",
|
||||
"calls": "audit.category_calls",
|
||||
"support_portal": "audit.category_support_portal",
|
||||
"demo": "audit.category_demo",
|
||||
}
|
||||
|
||||
AUDIT_CATEGORY_ORDER = (
|
||||
@@ -56,141 +58,149 @@ AUDIT_CATEGORY_ALIASES = {
|
||||
|
||||
AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
# --- Доступ и сотрудники ---
|
||||
"identity.login_succeeded": "Вход в систему",
|
||||
"identity.login_failed": "Неудачный вход",
|
||||
"identity.login_totp_required": "Запрошен код двухфакторной проверки",
|
||||
"identity.logout": "Выход из системы",
|
||||
"identity.password_reset_requested": "Запрошен сброс пароля",
|
||||
"identity.password_reset_completed": "Пароль восстановлен",
|
||||
"identity.password_reset_failed": "Неудачный сброс пароля",
|
||||
"identity.temporary_password_changed": "Сменён временный пароль",
|
||||
"identity.profile_updated": "Изменён профиль сотрудника",
|
||||
"identity.profile_password_changed": "Изменён пароль",
|
||||
"identity.profile_sessions_revoked": "Завершены свои сессии",
|
||||
"identity.profile_totp_setup_started": "Начата настройка двухфакторной проверки",
|
||||
"identity.profile_totp_disabled": "Отключена двухфакторная проверка",
|
||||
"identity.totp_enabled": "Включена двухфакторная проверка",
|
||||
"identity.totp_setup_failed": "Ошибка настройки двухфакторной проверки",
|
||||
"identity.totp_verify_failed": "Неверный код двухфакторной проверки",
|
||||
"identity.avatar_updated": "Изменено фото профиля",
|
||||
"identity.avatar_deleted": "Удалено фото профиля",
|
||||
"identity.employee_created": "Добавлен сотрудник",
|
||||
"identity.employee_updated": "Изменён сотрудник",
|
||||
"identity.employee_blocked": "Сотрудник заблокирован",
|
||||
"identity.employee_unblocked": "Сотрудник разблокирован",
|
||||
"identity.employee_role_changed": "Изменена роль сотрудника",
|
||||
"identity.employee_groups_changed": "Изменены группы сотрудника",
|
||||
"identity.employee_password_reset": "Сброшен пароль сотрудника",
|
||||
"identity.employee_sessions_terminated": "Завершены сессии сотрудника",
|
||||
"identity.employee_privileged_action_denied": "Отказано в привилегированном действии",
|
||||
"identity.group_created": "Создана группа",
|
||||
"identity.group_updated": "Изменена группа",
|
||||
"identity.group_deleted": "Удалена группа",
|
||||
"identity.ownership_transferred": "Передано владение организацией",
|
||||
"identity.owner_bootstrapped": "Создан владелец установки",
|
||||
"identity.instance_setup_completed": "Пройден мастер первого запуска",
|
||||
"identity.access_profile_created": "Создан профиль доступа",
|
||||
"identity.access_profile_updated": "Изменён профиль доступа",
|
||||
"identity.access_assignment_created": "Назначен доступ сотруднику",
|
||||
"identity.access_assignment_revoked": "Отозван доступ сотрудника",
|
||||
"access_assignment.created": "Назначен доступ сотруднику",
|
||||
"identity.login_succeeded": "audit.action_identity_login_succeeded",
|
||||
"identity.login_failed": "audit.action_identity_login_failed",
|
||||
"identity.login_totp_required": "audit.action_identity_login_totp_required",
|
||||
"identity.logout": "audit.action_identity_logout",
|
||||
"identity.password_reset_requested": "audit.action_identity_password_reset_requested",
|
||||
"identity.password_reset_completed": "audit.action_identity_password_reset_completed",
|
||||
"identity.password_reset_failed": "audit.action_identity_password_reset_failed",
|
||||
"identity.temporary_password_changed": "audit.action_identity_temporary_password_changed",
|
||||
"identity.profile_updated": "audit.action_identity_profile_updated",
|
||||
"identity.profile_language_changed": "audit.action_identity_profile_language_changed",
|
||||
"identity.profile_password_changed": "audit.action_identity_profile_password_changed",
|
||||
"identity.profile_sessions_revoked": "audit.action_identity_profile_sessions_revoked",
|
||||
"identity.profile_totp_setup_started": "audit.action_identity_profile_totp_setup_started",
|
||||
"identity.profile_totp_disabled": "audit.action_identity_profile_totp_disabled",
|
||||
"identity.totp_enabled": "audit.action_identity_totp_enabled",
|
||||
"identity.totp_setup_failed": "audit.action_identity_totp_setup_failed",
|
||||
"identity.totp_verify_failed": "audit.action_identity_totp_verify_failed",
|
||||
"identity.avatar_updated": "audit.action_identity_avatar_updated",
|
||||
"identity.avatar_deleted": "audit.action_identity_avatar_deleted",
|
||||
"identity.employee_created": "audit.action_identity_employee_created",
|
||||
"identity.employee_updated": "audit.action_identity_employee_updated",
|
||||
"identity.employee_blocked": "audit.action_identity_employee_blocked",
|
||||
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
|
||||
"identity.employee_role_changed": "audit.action_identity_employee_role_changed",
|
||||
"identity.employee_groups_changed": "audit.action_identity_employee_groups_changed",
|
||||
"identity.employee_password_reset": "audit.action_identity_employee_password_reset",
|
||||
"identity.employee_sessions_terminated": "audit.action_identity_employee_sessions_terminated",
|
||||
"identity.employee_privileged_action_denied": "audit.action_identity_employee_privileged_action_denied",
|
||||
"identity.group_created": "audit.action_identity_group_created",
|
||||
"identity.group_updated": "audit.action_identity_group_updated",
|
||||
"identity.group_deleted": "audit.action_identity_group_deleted",
|
||||
"identity.ownership_transferred": "audit.action_identity_ownership_transferred",
|
||||
"identity.owner_bootstrapped": "audit.action_identity_owner_bootstrapped",
|
||||
"identity.instance_setup_completed": "audit.action_identity_instance_setup_completed",
|
||||
"identity.access_profile_created": "audit.action_identity_access_profile_created",
|
||||
"identity.access_profile_updated": "audit.action_identity_access_profile_updated",
|
||||
"identity.access_assignment_created": "audit.action_identity_access_assignment_created",
|
||||
"identity.access_assignment_revoked": "audit.action_identity_access_assignment_revoked",
|
||||
"access_assignment.created": "audit.action_access_assignment_created",
|
||||
# --- Настройки ---
|
||||
"administration.organization_updated": "Изменены данные организации",
|
||||
"administration.logo_updated": "Изменён логотип организации",
|
||||
"administration.logo_deleted": "Удалён логотип организации",
|
||||
"administration.communication_updated": "Изменены голосовые и звонки",
|
||||
"administration.storage_updated": "Изменено хранилище файлов",
|
||||
"administration.storage_migration_requested": "Запущен перенос файлов",
|
||||
"administration.instance_updated": "Изменены настройки платформы",
|
||||
"administration.organization_updated": "audit.action_administration_organization_updated",
|
||||
"administration.logo_updated": "audit.action_administration_logo_updated",
|
||||
"administration.logo_deleted": "audit.action_administration_logo_deleted",
|
||||
"administration.communication_updated": "audit.action_administration_communication_updated",
|
||||
"administration.storage_updated": "audit.action_administration_storage_updated",
|
||||
"administration.storage_migration_requested": "audit.action_administration_storage_migration_requested",
|
||||
"administration.instance_updated": "audit.action_administration_instance_updated",
|
||||
# --- Организация ---
|
||||
"organization.provisioned": "Создана организация",
|
||||
"organization.owner_activated": "Активирован владелец организации",
|
||||
"organization.owner_invitation_requested": "Отправлено приглашение владельцу",
|
||||
"organization.provisioned": "audit.action_organization_provisioned",
|
||||
"organization.owner_activated": "audit.action_organization_owner_activated",
|
||||
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
|
||||
# --- Интеграции и каналы ---
|
||||
"integrations.integration_created": "Добавлена интеграция",
|
||||
"integrations.integration_updated": "Изменена интеграция",
|
||||
"integrations.integration_deleted": "Удалена интеграция",
|
||||
"channels.channel_created": "Создан канал",
|
||||
"channels.channel_updated": "Изменён канал",
|
||||
"channels.channel_deleted": "Удалён канал",
|
||||
"channels.connection_bound": "Подключение привязано к агенту",
|
||||
"integrations.integration_created": "audit.action_integrations_integration_created",
|
||||
"integrations.integration_updated": "audit.action_integrations_integration_updated",
|
||||
"integrations.integration_deleted": "audit.action_integrations_integration_deleted",
|
||||
"channels.channel_created": "audit.action_channels_channel_created",
|
||||
"channels.channel_updated": "audit.action_channels_channel_updated",
|
||||
"channels.channel_deleted": "audit.action_channels_channel_deleted",
|
||||
"channels.connection_bound": "audit.action_channels_connection_bound",
|
||||
# --- AI и знания ---
|
||||
"ai.agent_created": "Создан агент",
|
||||
"ai.agent_status_changed": "Изменён статус агента",
|
||||
"ai.agent_category_knowledge_selected": "Выбраны знания категории для агента",
|
||||
"ai.agent_knowledge_attached": "Знания подключены к агенту",
|
||||
"ai.agent_knowledge_detached": "Знания отключены от агента",
|
||||
"ai.agent_portal_articles_attached": "Статьи портала подключены к агенту",
|
||||
"ai.agent_portal_articles_detached": "Статьи портала отключены от агента",
|
||||
"ai.knowledge_created": "Добавлено знание",
|
||||
"ai.knowledge_updated": "Изменено знание",
|
||||
"ai.knowledge_deleted": "Удалено знание",
|
||||
"ai.knowledge_reindexed": "Знание переиндексировано",
|
||||
"ai.knowledge_imported": "Знания импортированы",
|
||||
"ai.knowledge_attachment_added": "Добавлено вложение знания",
|
||||
"ai.knowledge_attachment_deleted": "Удалено вложение знания",
|
||||
"ai.knowledge_category_created": "Создана категория знаний",
|
||||
"ai.knowledge_category_updated": "Изменена категория знаний",
|
||||
"ai.knowledge_category_deleted": "Удалена категория знаний",
|
||||
"ai.agent_created": "audit.action_ai_agent_created",
|
||||
"ai.agent_status_changed": "audit.action_ai_agent_status_changed",
|
||||
"ai.agent_category_knowledge_selected": "audit.action_ai_agent_category_knowledge_selected",
|
||||
"ai.agent_knowledge_attached": "audit.action_ai_agent_knowledge_attached",
|
||||
"ai.agent_knowledge_detached": "audit.action_ai_agent_knowledge_detached",
|
||||
"ai.agent_portal_articles_attached": "audit.action_ai_agent_portal_articles_attached",
|
||||
"ai.agent_portal_articles_detached": "audit.action_ai_agent_portal_articles_detached",
|
||||
"ai.knowledge_created": "audit.action_ai_knowledge_created",
|
||||
"ai.knowledge_updated": "audit.action_ai_knowledge_updated",
|
||||
"ai.knowledge_deleted": "audit.action_ai_knowledge_deleted",
|
||||
"ai.knowledge_reindexed": "audit.action_ai_knowledge_reindexed",
|
||||
"ai.knowledge_imported": "audit.action_ai_knowledge_imported",
|
||||
"ai.knowledge_attachment_added": "audit.action_ai_knowledge_attachment_added",
|
||||
"ai.knowledge_attachment_deleted": "audit.action_ai_knowledge_attachment_deleted",
|
||||
"ai.knowledge_category_created": "audit.action_ai_knowledge_category_created",
|
||||
"ai.knowledge_category_updated": "audit.action_ai_knowledge_category_updated",
|
||||
"ai.knowledge_category_deleted": "audit.action_ai_knowledge_category_deleted",
|
||||
# --- Диалоги ---
|
||||
"conversations.claimed": "Диалог взят в работу",
|
||||
"conversations.released_to_ai": "Диалог возвращён AI",
|
||||
"conversations.returned_to_queue": "Диалог возвращён в очередь",
|
||||
"conversations.assignee_changed": "Изменён ответственный за диалог",
|
||||
"conversations.group_changed": "Изменена группа диалога",
|
||||
"conversations.priority_changed": "Изменён приоритет диалога",
|
||||
"conversations.closed": "Диалог закрыт",
|
||||
"conversations.archived": "Диалог отправлен в архив",
|
||||
"conversations.unarchived": "Диалог возвращён из архива",
|
||||
"conversations.marked_spam": "Диалог помечен спамом",
|
||||
"conversations.contact_requested": "Запрошены контакты клиента",
|
||||
"conversations.contact_updated": "Изменён контакт диалога",
|
||||
"conversation.contact_updated": "Изменён контакт диалога",
|
||||
"conversations.file_sent": "Отправлен файл в диалог",
|
||||
"conversations.voice_sent": "Отправлено голосовое в диалог",
|
||||
"conversations.claimed": "audit.action_conversations_claimed",
|
||||
"conversations.released_to_ai": "audit.action_conversations_released_to_ai",
|
||||
"conversations.returned_to_queue": "audit.action_conversations_returned_to_queue",
|
||||
"conversations.assignee_changed": "audit.action_conversations_assignee_changed",
|
||||
"conversations.group_changed": "audit.action_conversations_group_changed",
|
||||
"conversations.priority_changed": "audit.action_conversations_priority_changed",
|
||||
"conversations.closed": "audit.action_conversations_closed",
|
||||
"conversations.archived": "audit.action_conversations_archived",
|
||||
"conversations.unarchived": "audit.action_conversations_unarchived",
|
||||
"conversations.marked_spam": "audit.action_conversations_marked_spam",
|
||||
"conversations.contact_requested": "audit.action_conversations_contact_requested",
|
||||
"conversations.contact_updated": "audit.action_conversations_contact_updated",
|
||||
"conversation.contact_updated": "audit.action_conversation_contact_updated",
|
||||
"conversations.file_sent": "audit.action_conversations_file_sent",
|
||||
"conversations.voice_sent": "audit.action_conversations_voice_sent",
|
||||
# --- Контакты ---
|
||||
"contacts.merged": "Объединение контактов",
|
||||
"contacts.unmerged": "Разъединение контактов",
|
||||
"contacts.merged": "audit.action_contacts_merged",
|
||||
"contacts.unmerged": "audit.action_contacts_unmerged",
|
||||
# --- Звонки ---
|
||||
"calls.requested": "Запрошен звонок",
|
||||
"calls.cancelled": "Звонок отменён",
|
||||
"calls.requested": "audit.action_calls_requested",
|
||||
"calls.cancelled": "audit.action_calls_cancelled",
|
||||
# --- Порталы ---
|
||||
"support_portal.created": "Создан портал",
|
||||
"support_portal.published": "Портал опубликован",
|
||||
"support_portal.draft": "Портал снят с публикации",
|
||||
"support_portal.articles_imported": "Импортированы статьи портала",
|
||||
"support_portal.created": "audit.action_support_portal_created",
|
||||
"support_portal.published": "audit.action_support_portal_published",
|
||||
"support_portal.draft": "audit.action_support_portal_draft",
|
||||
"support_portal.articles_imported": "audit.action_support_portal_articles_imported",
|
||||
# --- Демо-данные ---
|
||||
"demo.install_requested": "Запрошена установка демо-данных",
|
||||
"demo.installed": "Демо-данные установлены",
|
||||
"demo.installed_accounts": "Созданы учётные записи демо-сотрудников",
|
||||
"demo.remove_requested": "Запрошено удаление демо-данных",
|
||||
"demo.removed": "Демо-данные удалены",
|
||||
"demo.install_requested": "audit.action_demo_install_requested",
|
||||
"demo.installed": "audit.action_demo_installed",
|
||||
"demo.installed_accounts": "audit.action_demo_installed_accounts",
|
||||
"demo.remove_requested": "audit.action_demo_remove_requested",
|
||||
"demo.removed": "audit.action_demo_removed",
|
||||
}
|
||||
|
||||
def audit_result_label(result: str) -> str:
|
||||
"""Подпись результата события или «Неизвестно» для чужого кода."""
|
||||
|
||||
key = AUDIT_RESULT_LABELS.get(result)
|
||||
return t(key) if key else t("audit.result_unknown")
|
||||
|
||||
|
||||
AUDIT_RESULT_LABELS = {
|
||||
"SUCCESS": "Выполнено",
|
||||
"DENIED": "Отклонено",
|
||||
"FAILED": "Ошибка",
|
||||
"SUCCESS": "audit.result_success",
|
||||
"DENIED": "audit.result_denied",
|
||||
"FAILED": "audit.result_failed",
|
||||
}
|
||||
|
||||
# Что за объект тронули: тип из модели в человеческом виде. Пусто — покажем
|
||||
# сам object_type, он и так читается латиницей.
|
||||
AUDIT_OBJECT_TYPE_LABELS = {
|
||||
"Organization": "Организация",
|
||||
"HumanUser": "Сотрудник",
|
||||
"EmployeeGroup": "Группа",
|
||||
"Integration": "Интеграция",
|
||||
"Channel": "Канал",
|
||||
"Knowledge": "Знание",
|
||||
"KnowledgeCategory": "Категория знаний",
|
||||
"AgentCard": "Агент",
|
||||
"Conversation": "Диалог",
|
||||
"Contact": "Контакт",
|
||||
"SupportPortal": "Портал",
|
||||
"AccessProfile": "Профиль доступа",
|
||||
"AccessAssignment": "Назначение доступа",
|
||||
"InstanceSettings": "Установка",
|
||||
"StorageSettings": "Хранилище",
|
||||
"Organization": "audit.object_organization",
|
||||
"HumanUser": "audit.object_humanuser",
|
||||
"EmployeeGroup": "audit.object_employeegroup",
|
||||
"Integration": "audit.object_integration",
|
||||
"Channel": "audit.object_channel",
|
||||
"Knowledge": "audit.object_knowledge",
|
||||
"KnowledgeCategory": "audit.object_knowledgecategory",
|
||||
"AgentCard": "audit.object_agentcard",
|
||||
"Conversation": "audit.object_conversation",
|
||||
"Contact": "audit.object_contact",
|
||||
"SupportPortal": "audit.object_supportportal",
|
||||
"AccessProfile": "audit.object_accessprofile",
|
||||
"AccessAssignment": "audit.object_accessassignment",
|
||||
"InstanceSettings": "audit.object_instancesettings",
|
||||
"StorageSettings": "audit.object_storagesettings",
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +212,8 @@ def audit_category(action: str) -> str:
|
||||
|
||||
|
||||
def audit_category_label(category: str) -> str:
|
||||
return AUDIT_CATEGORY_LABELS.get(category, "Прочее")
|
||||
key = AUDIT_CATEGORY_LABELS.get(category)
|
||||
return t(key) if key else t("audit.category_other")
|
||||
|
||||
|
||||
def audit_action_label(action: str) -> str:
|
||||
@@ -213,7 +224,8 @@ def audit_action_label(action: str) -> str:
|
||||
надо дописать сюда.
|
||||
"""
|
||||
|
||||
return AUDIT_ACTION_LABELS.get(action, "")
|
||||
key = AUDIT_ACTION_LABELS.get(action)
|
||||
return t(key) if key else ""
|
||||
|
||||
|
||||
def audit_object_label(object_type: str, object_id: str) -> str:
|
||||
@@ -221,7 +233,8 @@ def audit_object_label(object_type: str, object_id: str) -> str:
|
||||
|
||||
if not object_type:
|
||||
return ""
|
||||
label = AUDIT_OBJECT_TYPE_LABELS.get(object_type, object_type)
|
||||
key = AUDIT_OBJECT_TYPE_LABELS.get(object_type)
|
||||
label = t(key) if key else object_type
|
||||
return f"{label} · {object_id}" if object_id else label
|
||||
|
||||
|
||||
@@ -229,6 +242,6 @@ def audit_categories() -> list[dict[str, str]]:
|
||||
"""Разделы для фильтра — в фиксированном порядке, а не в порядке словаря."""
|
||||
|
||||
return [
|
||||
{"value": key, "label": AUDIT_CATEGORY_LABELS[key]}
|
||||
{"value": key, "label": t(AUDIT_CATEGORY_LABELS[key])}
|
||||
for key in AUDIT_CATEGORY_ORDER
|
||||
]
|
||||
@@ -8,6 +8,7 @@ from chatballs.identity.auth.profile import (
|
||||
ChangeTemporaryPasswordView,
|
||||
ProfileAppearanceView,
|
||||
ProfileAvatarView,
|
||||
ProfileLanguageView,
|
||||
ProfilePasswordView,
|
||||
ProfileRevokeOtherSessionsView,
|
||||
ProfileSessionsView,
|
||||
@@ -28,6 +29,7 @@ __all__ = [
|
||||
"ProfileUpdateView",
|
||||
"ProfileAppearanceView",
|
||||
"ProfileAvatarView",
|
||||
"ProfileLanguageView",
|
||||
"ProfilePasswordView",
|
||||
"ProfileTotpStartView",
|
||||
"ProfileTotpDisableView",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.conf import settings
|
||||
from rest_framework.request import Request
|
||||
|
||||
from chatballs.i18n import current_language
|
||||
from chatballs.identity.avatars import own_avatar_url
|
||||
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.identity.policy import get_effective_access
|
||||
@@ -62,6 +63,12 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
|
||||
"deliveryMode": settings.CHATBALLS_DELIVERY_MODE,
|
||||
"uiTheme": user.ui_theme,
|
||||
"uiAccent": user.ui_accent,
|
||||
# Личный выбор языка: пустая строка — «как в организации». Отдельно
|
||||
# отдаётся язык, на котором интерфейс открывается прямо сейчас, — он
|
||||
# уже разрешён по цепочке профиль → организация → установка, и
|
||||
# фронтенду не нужно повторять это правило у себя.
|
||||
"uiLanguage": user.ui_language,
|
||||
"language": current_language(),
|
||||
"avatarUrl": own_avatar_url(user),
|
||||
"memberships": memberships,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.event_handlers import PASSWORD_RESET_REQUESTED
|
||||
from chatballs.identity.models import AuditResult, HumanUser
|
||||
@@ -87,7 +88,7 @@ class PasswordResetConfirmView(APIView):
|
||||
user = _user_from_reset_link(str(body.get("uid", "")), str(body.get("token", "")))
|
||||
if user is None:
|
||||
record_audit_event(action="identity.password_reset_failed", result=AuditResult.DENIED, request=request)
|
||||
return Response({"detail": "Ссылка недействительна или истекла"}, status=400)
|
||||
return Response({"detail": t("identity.link_invalid_or_expired")}, status=400)
|
||||
|
||||
new_password = str(body.get("newPassword", ""))
|
||||
try:
|
||||
|
||||
@@ -2,17 +2,20 @@ from django.contrib.auth import login
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.http import FileResponse
|
||||
from django.utils import timezone
|
||||
from django.utils import timezone, translation
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import normalize_language, resolve_language
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload
|
||||
from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
|
||||
from chatballs.identity.models import HumanUser
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
|
||||
@@ -53,7 +56,7 @@ class ProfileAvatarView(APIView):
|
||||
def get(self, request: Request) -> Response | FileResponse:
|
||||
user = request.user
|
||||
if not user.avatar:
|
||||
return Response({"detail": "Фото не задано"}, status=404)
|
||||
return Response({"detail": t("profile.photo_not_set")}, status=404)
|
||||
return FileResponse(
|
||||
user.avatar.open("rb"),
|
||||
content_type=user.avatar_content_type or "application/octet-stream",
|
||||
@@ -63,7 +66,7 @@ class ProfileAvatarView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "Выберите файл фото"}, status=400)
|
||||
return Response({"detail": t("profile.choose_photo_file")}, status=400)
|
||||
replace_user_avatar(request.user, upload)
|
||||
record_audit_event(
|
||||
action="identity.avatar_updated",
|
||||
@@ -128,7 +131,7 @@ class ProfileTotpStartView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
if request.user.totp_enabled:
|
||||
return Response(
|
||||
{"detail": "TOTP уже включена: сначала отключите её текущим паролем"},
|
||||
{"detail": t("profile.totp_already_on")},
|
||||
status=409,
|
||||
)
|
||||
request.user.totp_secret = ""
|
||||
@@ -230,10 +233,66 @@ class ProfileAppearanceView(APIView):
|
||||
theme = str(body.get("theme", request.user.ui_theme)).strip().upper()
|
||||
accent = str(body.get("accent", request.user.ui_accent)).strip().lower()
|
||||
if theme not in UiTheme.values:
|
||||
return Response({"detail": "Неизвестная тема"}, status=400)
|
||||
return Response({"detail": t("profile.unknown_theme")}, status=400)
|
||||
if accent and not re.fullmatch(r"#[0-9a-f]{6}", accent):
|
||||
return Response({"detail": "Акцент — HEX-цвет вида #1677ff"}, status=400)
|
||||
return Response({"detail": t("profile.accent_hex")}, status=400)
|
||||
request.user.ui_theme = theme
|
||||
request.user.ui_accent = accent
|
||||
request.user.save(update_fields=["ui_theme", "ui_accent"])
|
||||
return Response({"authenticated": True, "user": _user_payload(request.user)})
|
||||
|
||||
|
||||
class ProfileLanguageView(APIView):
|
||||
"""Язык интерфейса — личная настройка сотрудника.
|
||||
|
||||
Пустое значение возвращает человека к языку организации: это не «русский»,
|
||||
а снятие личного выбора, и после смены языка организации такой сотрудник
|
||||
поедет за ней, а тот, кто выбрал язык явно, — нет.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
raw = str(request.data.get("language", "")).strip()
|
||||
language = normalize_language(raw)
|
||||
if raw and not language:
|
||||
return Response({"detail": t("settings.language_unsupported")}, status=400)
|
||||
request.user.ui_language = language
|
||||
request.user.save(update_fields=["ui_language"])
|
||||
record_audit_event(
|
||||
action="identity.profile_language_changed",
|
||||
actor=request.user,
|
||||
object_type="HumanUser",
|
||||
object_id=str(request.user.id),
|
||||
request=request,
|
||||
)
|
||||
# Ответ уже на новом языке: интерфейс перерисуется по нему сразу, не
|
||||
# дожидаясь следующего запроса.
|
||||
resolved = resolve_language(
|
||||
user_language=language,
|
||||
organization_language=_request_organization_language(request),
|
||||
instance_language=default_language(),
|
||||
accept_language=request.headers.get("Accept-Language"),
|
||||
)
|
||||
with translation.override(resolved):
|
||||
return Response({"authenticated": True, "user": _user_payload(request.user)})
|
||||
|
||||
|
||||
def _request_organization_language(request: Request) -> str:
|
||||
"""Язык организации запроса, если он у этого запроса вообще есть.
|
||||
|
||||
Профиль открывается вне организации, поэтому tenant_context здесь обычно
|
||||
пуст: тогда язык берётся из единственного членства, а при нескольких —
|
||||
из того, что старше, чтобы ответ не зависел от порядка выборки.
|
||||
"""
|
||||
|
||||
context = getattr(request, "tenant_context", None)
|
||||
if context is not None:
|
||||
return context.organization.language or ""
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(user=request.user, blocked_at__isnull=True)
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
return membership.organization.language if membership is not None else ""
|
||||
@@ -9,6 +9,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import current_language
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.auth.common import _challenge_payload, _user_payload
|
||||
from chatballs.identity.auth.totp_utils import TOTP_SESSION_KEY, TOTP_STARTED_KEY
|
||||
@@ -22,7 +23,12 @@ class SessionView(APIView):
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"authenticated": False})
|
||||
# Язык установки нужен и до входа: логин и сброс пароля — экраны
|
||||
# самой коробки, и на английской машине они должны открываться на
|
||||
# языке установки, а не на языке браузера. Без этого фронтенд знал
|
||||
# бы только про браузер и расходился бы с бэкендом, который язык
|
||||
# установки учитывает.
|
||||
return Response({"authenticated": False, "language": current_language()})
|
||||
return Response({"authenticated": True, "user": _user_payload(request.user)})
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ urlpatterns = [
|
||||
path("password-reset/confirm/", auth.PasswordResetConfirmView.as_view(), name="auth-password-reset-confirm"),
|
||||
path("profile/update/", auth.ProfileUpdateView.as_view(), name="auth-profile-update"),
|
||||
path("profile/appearance/", auth.ProfileAppearanceView.as_view(), name="auth-profile-appearance"),
|
||||
path("profile/language/", auth.ProfileLanguageView.as_view(), name="auth-profile-language"),
|
||||
path("profile/avatar/", auth.ProfileAvatarView.as_view(), name="auth-profile-avatar"),
|
||||
path("profile/password/", auth.ProfilePasswordView.as_view(), name="auth-profile-password"),
|
||||
path("profile/totp/start/", auth.ProfileTotpStartView.as_view(), name="auth-profile-totp-start"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.core.mail import send_mail
|
||||
from django.utils import translation
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import resolve_language
|
||||
from chatballs.identity.instance_settings import (
|
||||
default_language,
|
||||
email_connection,
|
||||
email_from_address,
|
||||
public_base_url,
|
||||
@@ -19,34 +23,54 @@ def _password_setup_url(user: HumanUser) -> str:
|
||||
return f"{public_base_url()}/reset-password?uid={uid}&token={token}"
|
||||
|
||||
|
||||
def _recipient_language(user: HumanUser) -> str:
|
||||
"""Язык получателя письма, а не того, кто нажал кнопку.
|
||||
|
||||
Приглашение сотруднику отправляет владелец, и язык запроса здесь не при
|
||||
чём: письмо читает другой человек. Личный выбор получателя побеждает,
|
||||
дальше — язык его организации, дальше — язык установки.
|
||||
"""
|
||||
|
||||
membership = (
|
||||
user.memberships.select_related("organization")
|
||||
.filter(blocked_at__isnull=True)
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
return resolve_language(
|
||||
user_language=user.ui_language,
|
||||
organization_language=membership.organization.language if membership else "",
|
||||
instance_language=default_language(),
|
||||
)
|
||||
|
||||
|
||||
def send_initial_access_email(user: HumanUser) -> None:
|
||||
setup_url = _password_setup_url(user)
|
||||
send_mail(
|
||||
subject="Первичный доступ к Chatballs",
|
||||
message=(
|
||||
f"Здравствуйте, {user.full_name or user.email}.\n\n"
|
||||
"Для вас создана учётная запись Chatballs. Чтобы задать пароль первичного доступа, "
|
||||
"перейдите по ссылке:\n"
|
||||
f"{setup_url}\n\n"
|
||||
"Ссылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации."
|
||||
),
|
||||
from_email=email_from_address(),
|
||||
recipient_list=[user.email],
|
||||
connection=email_connection(),
|
||||
)
|
||||
with translation.override(_recipient_language(user)):
|
||||
send_mail(
|
||||
subject=t("emails.initial_access_subject"),
|
||||
message=t(
|
||||
"emails.initial_access_body",
|
||||
name=user.full_name or user.email,
|
||||
url=setup_url,
|
||||
),
|
||||
from_email=email_from_address(),
|
||||
recipient_list=[user.email],
|
||||
connection=email_connection(),
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset_email(user: HumanUser) -> None:
|
||||
reset_url = _password_setup_url(user)
|
||||
send_mail(
|
||||
subject="Восстановление доступа к Chatballs",
|
||||
message=(
|
||||
f"Здравствуйте, {user.full_name or user.email}.\n\n"
|
||||
"Вы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n"
|
||||
f"{reset_url}\n\n"
|
||||
"Ссылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо."
|
||||
),
|
||||
from_email=email_from_address(),
|
||||
recipient_list=[user.email],
|
||||
connection=email_connection(),
|
||||
)
|
||||
with translation.override(_recipient_language(user)):
|
||||
send_mail(
|
||||
subject=t("emails.password_reset_subject"),
|
||||
message=t(
|
||||
"emails.password_reset_body",
|
||||
name=user.full_name or user.email,
|
||||
url=reset_url,
|
||||
),
|
||||
from_email=email_from_address(),
|
||||
recipient_list=[user.email],
|
||||
connection=email_connection(),
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.employee_password import clean_password_mode, reset_employee_password
|
||||
from chatballs.identity.employee_support import employee_payload, get_owned_profile
|
||||
@@ -36,12 +37,7 @@ class EmployeeResetPasswordView(APIView):
|
||||
# организации, когда человек работает и в другой, нельзя.
|
||||
if profile.user.memberships.count() > 1:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Сотрудник состоит в нескольких организациях — "
|
||||
"пароль сбрасывает он сам через восстановление доступа"
|
||||
)
|
||||
},
|
||||
{"detail": t("admin.multi_org_self_reset")},
|
||||
status=409,
|
||||
)
|
||||
password = reset_employee_password(
|
||||
|
||||
@@ -7,6 +7,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
|
||||
from chatballs.identity.employee_selectors import employees_for
|
||||
@@ -50,7 +51,7 @@ class EmployeeAvatarView(APIView):
|
||||
.first()
|
||||
)
|
||||
if membership is None or not membership.user.avatar:
|
||||
return Response({"detail": "Фото не найдено"}, status=404)
|
||||
return Response({"detail": t("admin.photo_not_found")}, status=404)
|
||||
response = FileResponse(
|
||||
membership.user.avatar.open("rb"),
|
||||
content_type=membership.user.avatar_content_type or "application/octet-stream",
|
||||
|
||||
@@ -8,6 +8,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
@@ -81,11 +82,11 @@ class GroupListView(APIView):
|
||||
return Response({"detail": members_error}, status=400)
|
||||
color = _clean_color(request.data.get("color"))
|
||||
if color is None:
|
||||
return Response({"detail": "Некорректный цвет"}, status=400)
|
||||
return Response({"detail": t("conversations.invalid_colour")}, status=400)
|
||||
try:
|
||||
group = EmployeeGroup.objects.create(organization=organization, name=name, color=color)
|
||||
except (ValidationError, IntegrityError):
|
||||
return Response({"detail": "Группа с таким именем уже есть"}, status=400)
|
||||
return Response({"detail": t("admin.group_name_taken")}, status=400)
|
||||
if members:
|
||||
EmployeeGroupMember.objects.bulk_create(
|
||||
[
|
||||
@@ -124,7 +125,7 @@ class GroupDetailView(APIView):
|
||||
if "color" in request.data:
|
||||
color = _clean_color(request.data.get("color"))
|
||||
if color is None:
|
||||
return Response({"detail": "Некорректный цвет"}, status=400)
|
||||
return Response({"detail": t("conversations.invalid_colour")}, status=400)
|
||||
group.color = color
|
||||
if "name" in request.data or "color" in request.data:
|
||||
if "name" in request.data:
|
||||
@@ -132,7 +133,7 @@ class GroupDetailView(APIView):
|
||||
try:
|
||||
group.save()
|
||||
except (ValidationError, IntegrityError):
|
||||
return Response({"detail": "Группа с таким именем уже есть"}, status=400)
|
||||
return Response({"detail": t("admin.group_name_taken")}, status=400)
|
||||
members, members_error = _resolve_members(
|
||||
group.organization, request.data.get("memberIds")
|
||||
)
|
||||
|
||||
@@ -43,6 +43,11 @@ class InstanceSettings(models.Model):
|
||||
email_use_tls = models.BooleanField(default=True)
|
||||
email_from = models.CharField(max_length=255, blank=True, default="")
|
||||
|
||||
# Язык установки: на нём открываются экраны, где организации ещё нет, —
|
||||
# логин, сброс пароля, мастер первого запуска, — и он же служит умолчанием
|
||||
# для организаций, которые своего языка не выбрали.
|
||||
default_language = models.CharField(max_length=5, blank=True, default="ru")
|
||||
|
||||
# Адреса TURN-серверов для звонков через relay. Секрет сюда не пишется:
|
||||
# он общий с coturn и живёт в томе секретов, чтобы не вводить его дважды.
|
||||
turn_urls = models.TextField(blank=True, default="")
|
||||
@@ -107,6 +112,30 @@ def accepted_hosts() -> tuple[str, ...]:
|
||||
return tuple(host for host in _hosts() if host)
|
||||
|
||||
|
||||
def default_language() -> str:
|
||||
"""Язык установки: экраны до входа и умолчание для организаций.
|
||||
|
||||
Читается без кэша, в отличие от адреса. Кэш с TTL здесь означал бы, что
|
||||
один и тот же запрос стоит то одного запроса к базе, то нуля — в зависимости
|
||||
от того, попал ли он в окно, — и язык установки некоторое время оставался бы
|
||||
старым в тех процессах, которые смены не видели. Цена честности — один
|
||||
поиск singleton-строки по первичному ключу, и только у запросов, которые
|
||||
вообще досюда дошли: у сотрудника со своим языком и у организации со своим
|
||||
до этой ветки дело не доходит.
|
||||
|
||||
Пустая строка означает установку, где язык ещё ни разу не задавали, — и
|
||||
приводит к языку по умолчанию из ``chatballs.i18n``.
|
||||
"""
|
||||
|
||||
from chatballs.i18n.languages import normalize_language
|
||||
|
||||
try:
|
||||
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
|
||||
except Exception: # таблицы ещё нет (первые миграции)
|
||||
return ""
|
||||
return normalize_language(row.default_language) if row is not None else ""
|
||||
|
||||
|
||||
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
|
||||
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_language
|
||||
from chatballs.identity.instance_settings import (
|
||||
InstanceSettings,
|
||||
email_connection,
|
||||
@@ -30,6 +32,9 @@ def instance_payload(row: InstanceSettings) -> dict:
|
||||
"publicHost": row.public_host,
|
||||
"publicScheme": row.public_scheme or "http",
|
||||
"publicUrl": public_base_url(),
|
||||
# Язык экранов, где организации ещё нет: логин, сброс пароля, мастер.
|
||||
"defaultLanguage": row.default_language or DEFAULT_LANGUAGE,
|
||||
"languages": [{"code": code, "label": label} for code, label in LANGUAGES],
|
||||
"updatedAt": row.updated_at,
|
||||
"email": {
|
||||
"host": row.email_host,
|
||||
@@ -70,31 +75,43 @@ class InstanceAddressView(APIView):
|
||||
raw_host = raw_host.split("//", 1)[1]
|
||||
host = normalize_domain(raw_host.split("/", 1)[0].split(":", 1)[0])
|
||||
if not host:
|
||||
errors["publicHost"] = "Укажите адрес установки"
|
||||
errors["publicHost"] = t("settings.address_required")
|
||||
else:
|
||||
try:
|
||||
# IP-адрес — обычный случай коробки: домена может не быть вовсе.
|
||||
if not _looks_like_ipv4(host):
|
||||
validate_domain(host)
|
||||
except ValidationError:
|
||||
errors["publicHost"] = "Некорректный адрес"
|
||||
errors["publicHost"] = t("settings.invalid_address")
|
||||
|
||||
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
|
||||
if scheme not in SCHEMES:
|
||||
errors["publicScheme"] = "http или https"
|
||||
errors["publicScheme"] = t("settings.http_or_https")
|
||||
|
||||
raw_language = str(body.get("defaultLanguage", row.default_language)).strip()
|
||||
language = normalize_language(raw_language)
|
||||
if raw_language and not language:
|
||||
errors["defaultLanguage"] = t("settings.language_unsupported")
|
||||
|
||||
if errors:
|
||||
return Response(
|
||||
{"detail": next(iter(errors.values())), "errors": errors}, status=400
|
||||
)
|
||||
|
||||
fields = ["public_host", "public_scheme", "previous_public_host", "updated_at"]
|
||||
fields = [
|
||||
"public_host",
|
||||
"public_scheme",
|
||||
"previous_public_host",
|
||||
"default_language",
|
||||
"updated_at",
|
||||
]
|
||||
if host != row.public_host:
|
||||
# Прежний адрес остаётся принятым: владелец меняет адрес заранее,
|
||||
# сидя на старом, и не должен выпасть из установки в тот же миг.
|
||||
row.previous_public_host = row.public_host
|
||||
row.public_host = host
|
||||
row.public_scheme = scheme
|
||||
row.default_language = language or DEFAULT_LANGUAGE
|
||||
|
||||
email = body.get("email")
|
||||
if isinstance(email, dict):
|
||||
@@ -106,7 +123,7 @@ class InstanceAddressView(APIView):
|
||||
row.email_port = int(email.get("port", row.email_port))
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"detail": "Порт — число", "errors": {"emailPort": "Порт — число"}},
|
||||
{"detail": t("settings.port_is_number"), "errors": {"emailPort": t("settings.port_is_number")}},
|
||||
status=400,
|
||||
)
|
||||
# Пустой пароль означает «оставить прежний»: наружу он не отдаётся.
|
||||
@@ -131,7 +148,7 @@ class InstanceAddressView(APIView):
|
||||
row.turn_ttl_seconds = max(60, int(turn.get("ttlSeconds", row.turn_ttl_seconds)))
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"detail": "Время жизни — число секунд",
|
||||
{"detail": t("settings.ttl_is_seconds"),
|
||||
"errors": {"turnTtlSeconds": "Число секунд"}},
|
||||
status=400,
|
||||
)
|
||||
@@ -163,7 +180,7 @@ class InstanceEmailCheckView(APIView):
|
||||
connection = email_connection()
|
||||
if connection is None:
|
||||
return Response(
|
||||
{"detail": "Сначала укажите сервер исходящей почты"}, status=400
|
||||
{"detail": t("settings.smtp_required_first")}, status=400
|
||||
)
|
||||
recipient = str(request.data.get("email", "")).strip() or request.user.email
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-09 18:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('identity', '0034_encrypted_column_widths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='humanuser',
|
||||
name='ui_language',
|
||||
field=models.CharField(blank=True, default='', max_length=5),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='instancesettings',
|
||||
name='default_language',
|
||||
field=models.CharField(blank=True, default='ru', max_length=5),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='language',
|
||||
field=models.CharField(blank=True, default='', max_length=5),
|
||||
),
|
||||
]
|
||||
@@ -78,6 +78,12 @@ class HumanUser(AbstractUser):
|
||||
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
|
||||
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
|
||||
ui_accent = models.CharField(max_length=9, blank=True)
|
||||
# Язык интерфейса — тоже глобальная настройка пользователя, а не
|
||||
# membership: человек читает на одном языке во всех организациях, куда
|
||||
# входит. Пустая строка — «как в организации»: это не английский и не
|
||||
# русский, а отсутствие личного выбора, и она переживает смену языка
|
||||
# организации, тогда как записанный при регистрации код — нет.
|
||||
ui_language = models.CharField(max_length=5, blank=True, default="")
|
||||
# Фото сотрудника (дизайн-базлайн v2): видно коллегам в сайдбаре, подписи
|
||||
# сообщений, выборе ответственного. Загружается в профиле.
|
||||
avatar = models.FileField(upload_to=user_avatar_upload_path, storage=user_storage, max_length=512, blank=True, default="")
|
||||
@@ -115,6 +121,11 @@ class Organization(models.Model):
|
||||
)
|
||||
timezone = models.CharField(max_length=64, default="Europe/Moscow")
|
||||
currency = models.CharField(max_length=3, default="RUB")
|
||||
# Язык рабочего места по умолчанию: на нём организация открывается всем,
|
||||
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
|
||||
# это такой же региональный параметр организации, и в «Настройках» они
|
||||
# лежат в одном разделе.
|
||||
language = models.CharField(max_length=5, blank=True, default="")
|
||||
logo = models.FileField(
|
||||
upload_to=organization_logo_upload_path,
|
||||
max_length=512,
|
||||
|
||||
@@ -2,6 +2,8 @@ import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
# Единые правила сложности пароля, зеркалят клиентскую проверку internal-ui.
|
||||
_LETTER = re.compile(r"[A-Za-zА-Яа-я]")
|
||||
_DIGIT = re.compile(r"\d")
|
||||
@@ -13,11 +15,11 @@ class PasswordComplexityValidator:
|
||||
|
||||
def validate(self, password: str, user: object = None) -> None:
|
||||
if not _LETTER.search(password):
|
||||
raise ValidationError("Пароль должен содержать букву.", code="password_no_letter")
|
||||
raise ValidationError(t("identity.password_needs_letter"), code="password_no_letter")
|
||||
if not _DIGIT.search(password):
|
||||
raise ValidationError("Пароль должен содержать цифру.", code="password_no_digit")
|
||||
raise ValidationError(t("identity.password_needs_digit"), code="password_no_digit")
|
||||
if not _SPECIAL.search(password):
|
||||
raise ValidationError("Пароль должен содержать спецсимвол.", code="password_no_special")
|
||||
raise ValidationError(t("identity.password_needs_special"), code="password_no_special")
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return "Пароль должен содержать букву, цифру и спецсимвол."
|
||||
@@ -22,6 +22,7 @@ from django.db import connections, transaction
|
||||
from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.instance_settings import remember_public_host
|
||||
from chatballs.identity.models import (
|
||||
@@ -76,7 +77,7 @@ def _clean(data: SetupInput) -> SetupInput:
|
||||
errors: dict[str, str] = {}
|
||||
organization_name = data.organization_name.strip()
|
||||
if not organization_name:
|
||||
errors["organizationName"] = "Укажите название организации"
|
||||
errors["organizationName"] = t("admin.organization_name_required")
|
||||
elif len(organization_name) > ORGANIZATION_NAME_MAX_LENGTH:
|
||||
errors["organizationName"] = "Название длиннее 255 символов"
|
||||
full_name = " ".join(data.full_name.split())
|
||||
|
||||
@@ -8,6 +8,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.auth.common import _user_payload
|
||||
from chatballs.identity.setup import (
|
||||
SetupAlreadyCompleted,
|
||||
@@ -16,7 +17,10 @@ from chatballs.identity.setup import (
|
||||
instance_needs_setup,
|
||||
)
|
||||
|
||||
SETUP_CLOSED = {"detail": "Первый запуск уже выполнен: организация создана"}
|
||||
|
||||
# Функция, а не константа: язык запроса решается на каждый запрос заново.
|
||||
def setup_closed() -> dict[str, str]:
|
||||
return {"detail": t("identity.setup_already_done")}
|
||||
|
||||
|
||||
def _validation_response(error: ValidationError) -> Response:
|
||||
@@ -54,7 +58,7 @@ class SetupView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
if not instance_needs_setup():
|
||||
return Response(SETUP_CLOSED, status=409)
|
||||
return Response(setup_closed(), status=409)
|
||||
try:
|
||||
result = complete_setup(
|
||||
SetupInput(
|
||||
@@ -68,7 +72,7 @@ class SetupView(APIView):
|
||||
public_scheme=request.scheme,
|
||||
)
|
||||
except SetupAlreadyCompleted:
|
||||
return Response(SETUP_CLOSED, status=409)
|
||||
return Response(setup_closed(), status=409)
|
||||
except ValidationError as error:
|
||||
return _validation_response(error)
|
||||
owner = result.owner
|
||||
|
||||
@@ -61,6 +61,8 @@ class AdministrationApiTests(TestCase):
|
||||
"name": "Example",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
# Пустой язык — «как в установке»: организация своего не выбрала.
|
||||
"language": "",
|
||||
"logoUrl": None,
|
||||
# Подпись «Сохранено …» у кнопки (кадр N1): пока правок не было — null.
|
||||
"updatedAt": None,
|
||||
|
||||
@@ -325,6 +325,45 @@ class AuthEndpointTests(TestCase):
|
||||
)
|
||||
self.assertEqual(rejected.status_code, 400)
|
||||
|
||||
def test_profile_language_saves_personal_choice(self) -> None:
|
||||
"""Язык — личная настройка; пустое значение возвращает к организации."""
|
||||
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/profile/language/",
|
||||
data=json.dumps({"language": "en"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["user"]
|
||||
self.assertEqual(payload["uiLanguage"], "en")
|
||||
# Ответ уже на новом языке: интерфейс перерисуется по нему сразу.
|
||||
self.assertEqual(payload["language"], "en")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.assertEqual(owner.ui_language, "en")
|
||||
self.assertTrue(
|
||||
AuditEvent.objects.filter(action="identity.profile_language_changed").exists()
|
||||
)
|
||||
|
||||
cleared = self.client.post(
|
||||
"/api/v1/auth/profile/language/",
|
||||
data=json.dumps({"language": ""}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(cleared.status_code, 200)
|
||||
# Пустая строка — не язык, а снятие личного выбора: человек снова едет
|
||||
# за организацией.
|
||||
self.assertEqual(cleared.json()["user"]["uiLanguage"], "")
|
||||
|
||||
rejected = self.client.post(
|
||||
"/api/v1/auth/profile/language/",
|
||||
data=json.dumps({"language": "de"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(rejected.status_code, 400)
|
||||
|
||||
def test_profile_password_changes_current_user_password(self) -> None:
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.integrations.features import supports_calls
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
@@ -50,7 +51,7 @@ class CommunicationSettingsView(APIView):
|
||||
organization = request.tenant_context.organization
|
||||
items = request.data.get("items")
|
||||
if not isinstance(items, list):
|
||||
return Response({"detail": "Ожидается список items"}, status=400)
|
||||
return Response({"detail": t("settings.items_list_expected")}, status=400)
|
||||
by_id = {i.id: i for i in _entry_points(organization)}
|
||||
changed: list[dict[str, object]] = []
|
||||
for raw in items:
|
||||
@@ -58,7 +59,7 @@ class CommunicationSettingsView(APIView):
|
||||
continue
|
||||
integration = by_id.get(raw.get("id"))
|
||||
if integration is None:
|
||||
return Response({"detail": "Точка входа не найдена"}, status=404)
|
||||
return Response({"detail": t("settings.entry_point_not_found")}, status=404)
|
||||
fields: list[str] = []
|
||||
for key, attr in (("voiceMessages", "voice_messages_enabled"), ("audioCalls", "audio_calls_enabled"), ("videoCalls", "video_calls_enabled")):
|
||||
if key in raw:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.integrations.models import Integration
|
||||
from chatballs.integrations.selectors import (
|
||||
@@ -115,7 +116,7 @@ class IntegrationDetailView(APIView):
|
||||
data=_input(request.data, current=integration),
|
||||
)
|
||||
except Integration.DoesNotExist:
|
||||
return Response({"detail": "Интеграция не найдена"}, status=404)
|
||||
return Response({"detail": t("settings.integration_not_found")}, status=404)
|
||||
except (ValidationError, IntegrityError) as error:
|
||||
return _validation_error(error)
|
||||
_audit(request, "integrations.integration_updated", integration)
|
||||
@@ -125,7 +126,7 @@ class IntegrationDetailView(APIView):
|
||||
try:
|
||||
integration = self._get(request, integration_id)
|
||||
except Integration.DoesNotExist:
|
||||
return Response({"detail": "Интеграция не найдена"}, status=404)
|
||||
return Response({"detail": t("settings.integration_not_found")}, status=404)
|
||||
_audit(request, "integrations.integration_deleted", integration)
|
||||
delete_integration(context=request.tenant_context, integration=integration)
|
||||
return Response(status=204)
|
||||
@@ -141,7 +142,7 @@ class IntegrationTestView(APIView):
|
||||
context=request.tenant_context, integration_id=integration_id
|
||||
)
|
||||
except Integration.DoesNotExist:
|
||||
return Response({"detail": "Интеграция не найдена"}, status=404)
|
||||
return Response({"detail": t("settings.integration_not_found")}, status=404)
|
||||
integration = test_integration(
|
||||
context=request.tenant_context, integration=integration
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-09 19:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0009_alter_notification_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='body_key',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='text_params',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='title_key',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -33,8 +33,16 @@ class Notification(models.Model):
|
||||
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="notifications")
|
||||
type = models.CharField(max_length=32, choices=NotificationType.choices)
|
||||
level = models.CharField(max_length=16, choices=NotificationLevel.choices, default=NotificationLevel.INFO)
|
||||
# Текст на языке того, кто писал код: остаётся и для записей, сделанных
|
||||
# до появления ключей, и для доставки в мессенджер, где рендерить нечем.
|
||||
title = models.CharField(max_length=255)
|
||||
body = models.TextField(blank=True)
|
||||
# Ключи каталога и их параметры. Уведомление адресовано аудитории
|
||||
# («операторам»), а не одному человеку, и разные операторы могут читать на
|
||||
# разных языках — поэтому фраза собирается при показе, а не при записи.
|
||||
title_key = models.CharField(max_length=64, blank=True, default="")
|
||||
body_key = models.CharField(max_length=64, blank=True, default="")
|
||||
text_params = models.JSONField(default=dict, blank=True)
|
||||
# Диплинк по клику.
|
||||
target_route = models.CharField(max_length=64, blank=True)
|
||||
target_id = models.CharField(max_length=64, blank=True)
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
from chatballs.i18n import t
|
||||
from chatballs.notifications.models import Notification
|
||||
|
||||
|
||||
def _text(key: str, stored: str, params: dict) -> str:
|
||||
"""Фраза на языке запроса, если у записи есть ключ каталога.
|
||||
|
||||
Уведомление адресовано аудитории, а не одному человеку: два оператора
|
||||
одной организации могут читать на разных языках, и записать готовую фразу
|
||||
в момент события нельзя. Записи, сделанные до появления ключей, приходят
|
||||
сохранённым текстом — переписывать историю задним числом не за что.
|
||||
"""
|
||||
|
||||
return t(key, **params) if key else stored
|
||||
|
||||
|
||||
def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]:
|
||||
params = notification.text_params or {}
|
||||
return {
|
||||
"id": notification.id,
|
||||
"type": notification.type,
|
||||
"level": notification.level,
|
||||
"title": notification.title,
|
||||
"body": notification.body,
|
||||
"title": _text(notification.title_key, notification.title, params),
|
||||
"body": _text(notification.body_key, notification.body, params),
|
||||
"targetRoute": notification.target_route,
|
||||
"targetId": notification.target_id,
|
||||
"createdAt": notification.created_at.isoformat(),
|
||||
|
||||
@@ -31,6 +31,9 @@ def notify(
|
||||
audience: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
title_key: str = "",
|
||||
body_key: str = "",
|
||||
text_params: dict | None = None,
|
||||
target_id: object = "",
|
||||
recipient_user=None,
|
||||
source_type: str = "",
|
||||
@@ -51,6 +54,9 @@ def notify(
|
||||
audience=audience,
|
||||
title=title,
|
||||
body=body,
|
||||
title_key=title_key,
|
||||
body_key=body_key,
|
||||
text_params=text_params or {},
|
||||
level=level or meta.get("level", NotificationLevel.INFO),
|
||||
target_route=target_route,
|
||||
target_id=str(target_id) if target_id else "",
|
||||
|
||||
@@ -3,6 +3,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.notifications.binding import deep_link, issue_binding_code, notifier_integrations
|
||||
from chatballs.notifications.models import MessengerBinding, NotificationRead, NotificationType
|
||||
from chatballs.notifications.selectors import unread_for, visible_for
|
||||
@@ -69,7 +70,7 @@ class MessengerBindingDetailView(APIView):
|
||||
"""Выдать одноразовый код привязки и deep-link на бота."""
|
||||
integration = self._integration(request, integration_id)
|
||||
if integration is None:
|
||||
return Response({"detail": "Бот уведомлений не найден"}, status=404)
|
||||
return Response({"detail": t("profile.notification_bot_not_found")}, status=404)
|
||||
binding_code = issue_binding_code(
|
||||
context=request.tenant_context, integration=integration
|
||||
)
|
||||
@@ -86,7 +87,7 @@ class MessengerBindingDetailView(APIView):
|
||||
"""Обновить типы уведомлений, доставляемые в мессенджер."""
|
||||
binding = MessengerBinding.objects.filter(user=request.user, integration_id=integration_id).first()
|
||||
if binding is None:
|
||||
return Response({"detail": "Привязка не найдена"}, status=404)
|
||||
return Response({"detail": t("profile.link_not_found")}, status=404)
|
||||
types = request.data.get("pushTypes")
|
||||
if not isinstance(types, list):
|
||||
return Response({"detail": "pushTypes must be a list"}, status=400)
|
||||
|
||||
@@ -6,6 +6,8 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
|
||||
|
||||
@@ -16,10 +18,10 @@ def normalize_domain(value: str) -> str:
|
||||
def validate_domain(value: str) -> str:
|
||||
domain = normalize_domain(value)
|
||||
if not domain or len(domain) > 253:
|
||||
raise ValidationError("Некорректное доменное имя")
|
||||
raise ValidationError(t("portals.invalid_domain"))
|
||||
labels = domain.split(".")
|
||||
if len(labels) < 2 or any(not HOST_LABEL_RE.fullmatch(label) for label in labels):
|
||||
raise ValidationError("Некорректное доменное имя")
|
||||
raise ValidationError(t("portals.invalid_domain"))
|
||||
return domain
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.support_portals.article_import import import_articles
|
||||
from chatballs.support_portals.portal_views import PortalBaseView
|
||||
@@ -10,7 +11,7 @@ class ArticleImportView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
articles = request.data.get("articles")
|
||||
if not isinstance(articles, list) or not articles:
|
||||
return Response({"detail": "articles must be a non-empty list"}, status=400)
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.indexing import reindex_portal_article
|
||||
from chatballs.conversations.transports.base import guess_content_type, safe_filename
|
||||
from chatballs.i18n import t
|
||||
from chatballs.support_portals.content_markdown import normalize_file_links
|
||||
from chatballs.support_portals.models import (
|
||||
PortalArticle,
|
||||
@@ -193,7 +194,7 @@ def _category(portal: SupportPortal, category_id) -> PortalCategory:
|
||||
try:
|
||||
return portal.categories.get(id=int(category_id))
|
||||
except (PortalCategory.DoesNotExist, TypeError, ValueError) as error:
|
||||
raise ValidationError({"categoryId": "Раздел не найден"}) from error
|
||||
raise ValidationError({"categoryId": t("portals.section_not_found")}) from error
|
||||
|
||||
|
||||
def ensure_portal_editable(portal: SupportPortal) -> None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.i18n import t
|
||||
from chatballs.support_portals.api import validation_response
|
||||
from chatballs.support_portals.content_services import (
|
||||
add_article_file,
|
||||
@@ -47,7 +48,7 @@ class CategoryListView(PortalBaseView):
|
||||
def get(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
counts = category_article_counts(portal)
|
||||
return Response(
|
||||
{
|
||||
@@ -61,7 +62,7 @@ class CategoryListView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
try:
|
||||
category = create_category(
|
||||
context=request.tenant_context,
|
||||
@@ -71,7 +72,7 @@ class CategoryListView(PortalBaseView):
|
||||
except (ValidationError, TypeError, ValueError) as error:
|
||||
if isinstance(error, ValidationError):
|
||||
return validation_response(error)
|
||||
return Response({"detail": "Некорректная категория"}, status=400)
|
||||
return Response({"detail": t("portals.invalid_category")}, status=400)
|
||||
return Response({"category": category_payload(category)}, status=201)
|
||||
|
||||
|
||||
@@ -87,9 +88,9 @@ class CategoryDetailView(PortalBaseView):
|
||||
) -> Response:
|
||||
portal, category = self._category(request, portal_id, category_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
if category is None:
|
||||
return Response({"detail": "Раздел не найден"}, status=404)
|
||||
return Response({"detail": t("portals.section_not_found")}, status=404)
|
||||
try:
|
||||
category = update_category(
|
||||
portal=portal, category=category, data=request.data
|
||||
@@ -97,7 +98,7 @@ class CategoryDetailView(PortalBaseView):
|
||||
except (ValidationError, TypeError, ValueError) as error:
|
||||
if isinstance(error, ValidationError):
|
||||
return validation_response(error)
|
||||
return Response({"detail": "Некорректный раздел"}, status=400)
|
||||
return Response({"detail": t("portals.invalid_section")}, status=400)
|
||||
return Response({"category": category_payload(category)})
|
||||
|
||||
def delete(
|
||||
@@ -105,9 +106,9 @@ class CategoryDetailView(PortalBaseView):
|
||||
) -> Response:
|
||||
portal, category = self._category(request, portal_id, category_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
if category is None:
|
||||
return Response({"detail": "Раздел не найден"}, status=404)
|
||||
return Response({"detail": t("portals.section_not_found")}, status=404)
|
||||
try:
|
||||
delete_category(portal=portal, category=category)
|
||||
except ValidationError as error:
|
||||
@@ -119,7 +120,7 @@ class ArticleListView(PortalBaseView):
|
||||
def get(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
"""Страница библиотеки: категория, язык, статус и поиск — на сервере."""
|
||||
page = paginate(
|
||||
portal_articles_queryset(portal, request.query_params),
|
||||
@@ -131,7 +132,7 @@ class ArticleListView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
try:
|
||||
article = create_article(
|
||||
context=request.tenant_context,
|
||||
@@ -141,7 +142,7 @@ class ArticleListView(PortalBaseView):
|
||||
except (ValidationError, TypeError, ValueError) as error:
|
||||
if isinstance(error, ValidationError):
|
||||
return validation_response(error)
|
||||
return Response({"detail": "Некорректная статья"}, status=400)
|
||||
return Response({"detail": t("portals.invalid_article")}, status=400)
|
||||
article = _article(portal, article.id)
|
||||
return Response(
|
||||
{"article": article_payload(article, revisions=True, files=True)},
|
||||
@@ -153,25 +154,25 @@ class ArticleDetailView(PortalBaseView):
|
||||
def get(self, request: Request, portal_id: int, article_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
article = _article(portal, article_id)
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
return Response({"article": article_payload(article, revisions=True, files=True)})
|
||||
|
||||
def patch(self, request: Request, portal_id: int, article_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
article = _article(portal, article_id)
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
try:
|
||||
article = update_article(article=article, data=request.data)
|
||||
except (ValidationError, TypeError, ValueError) as error:
|
||||
if isinstance(error, ValidationError):
|
||||
return validation_response(error)
|
||||
return Response({"detail": "Некорректные данные статьи"}, status=400)
|
||||
return Response({"detail": t("portals.invalid_article_data")}, status=400)
|
||||
article = _article(portal, article.id)
|
||||
return Response({"article": article_payload(article, revisions=True, files=True)})
|
||||
|
||||
@@ -180,10 +181,10 @@ class ArticleRevisionListView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
article = _article(portal, article_id)
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
try:
|
||||
revision = add_revision(
|
||||
context=request.tenant_context,
|
||||
@@ -200,10 +201,10 @@ class ArticlePublishView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
article = _article(portal, article_id)
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
try:
|
||||
article = publish_revision(
|
||||
article=article,
|
||||
@@ -212,7 +213,7 @@ class ArticlePublishView(PortalBaseView):
|
||||
except (ValidationError, TypeError, ValueError) as error:
|
||||
if isinstance(error, ValidationError):
|
||||
return validation_response(error)
|
||||
return Response({"detail": "Некорректная версия"}, status=400)
|
||||
return Response({"detail": t("portals.invalid_revision")}, status=400)
|
||||
article = _article(portal, article.id)
|
||||
return Response({"article": article_payload(article, revisions=True, files=True)})
|
||||
|
||||
@@ -221,10 +222,10 @@ class ArticleArchiveView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
article = _article(portal, article_id)
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
article = archive_article(article)
|
||||
return Response({"article": article_payload(article, revisions=True, files=True)})
|
||||
|
||||
@@ -237,10 +238,10 @@ class ArticleFileListView(PortalBaseView):
|
||||
def get(self, request: Request, portal_id: int, article_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
article = _article(portal, article_id)
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
return Response(
|
||||
{"items": [article_file_payload(item) for item in article.files.all()]}
|
||||
)
|
||||
@@ -248,14 +249,14 @@ class ArticleFileListView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
article = _article(portal, article_id)
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response(
|
||||
{"detail": "Нужен файл в поле file (multipart/form-data)"}, status=400
|
||||
{"detail": t("portals.file_field_required")}, status=400
|
||||
)
|
||||
try:
|
||||
article_file = add_article_file(
|
||||
@@ -275,13 +276,13 @@ class ArticleFileDetailView(PortalBaseView):
|
||||
) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
article = _article(portal, article_id)
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
article_file = article.files.filter(id=file_id).first()
|
||||
if article_file is None:
|
||||
return Response({"detail": "Файл не найден"}, status=404)
|
||||
return Response({"detail": t("portals.file_not_found")}, status=404)
|
||||
try:
|
||||
delete_article_file(
|
||||
context=request.tenant_context, article_file=article_file
|
||||
|
||||
@@ -6,6 +6,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
|
||||
from chatballs.support_portals.api import validation_response
|
||||
@@ -173,14 +174,14 @@ class PortalDetailView(PortalBaseView):
|
||||
def get(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
counts = portal_content_counts(request.tenant_context).get(portal.id, {})
|
||||
return Response({"portal": portal_payload(portal, counts=counts)})
|
||||
|
||||
def patch(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
try:
|
||||
update_portal(
|
||||
context=request.tenant_context,
|
||||
@@ -197,7 +198,7 @@ class PortalStatusView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
try:
|
||||
portal = set_portal_status(
|
||||
context=request.tenant_context,
|
||||
@@ -224,7 +225,7 @@ class PortalWidgetOptionsView(PortalBaseView):
|
||||
def get(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
widgets = (
|
||||
WebChatWidget.objects.select_related(
|
||||
"integration",
|
||||
@@ -248,7 +249,7 @@ class PortalDomainView(PortalBaseView):
|
||||
def put(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
try:
|
||||
portal = set_custom_domain(
|
||||
portal, str(request.data.get("customDomain", ""))
|
||||
@@ -263,7 +264,7 @@ class PortalDomainVerifyView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
try:
|
||||
portal = verify_custom_domain(portal)
|
||||
except ValidationError as error:
|
||||
|
||||
@@ -4,6 +4,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.support_portals.content_services import record_feedback
|
||||
from chatballs.support_portals.models import SupportPortal
|
||||
@@ -55,7 +56,7 @@ class PublicPortalDetailView(PublicPortalView):
|
||||
def get(self, request: Request) -> Response:
|
||||
resolved = self.resolve(request)
|
||||
if resolved is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
context, portal = resolved
|
||||
with tenant_atomic(context):
|
||||
counts = category_article_counts(portal, published_only=True)
|
||||
@@ -76,7 +77,7 @@ class PublicArticleListView(PublicPortalView):
|
||||
def get(self, request: Request) -> Response:
|
||||
resolved = self.resolve(request)
|
||||
if resolved is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
context, portal = resolved
|
||||
locale = str(request.query_params.get("locale", portal.default_locale))
|
||||
with tenant_atomic(context):
|
||||
@@ -90,7 +91,7 @@ class PublicArticleListView(PublicPortalView):
|
||||
limit = min(max(int(request.query_params.get("limit", 50)), 1), 100)
|
||||
offset = max(int(request.query_params.get("offset", 0)), 0)
|
||||
except (TypeError, ValueError):
|
||||
return Response({"detail": "Некорректная пагинация"}, status=400)
|
||||
return Response({"detail": t("portals.invalid_pagination")}, status=400)
|
||||
total = articles.count()
|
||||
page = articles[offset : offset + limit]
|
||||
return Response(
|
||||
@@ -113,7 +114,7 @@ class PublicArticleDetailView(PublicPortalView):
|
||||
def get(self, request: Request, article_slug: str) -> Response:
|
||||
resolved = self.resolve(request)
|
||||
if resolved is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
context, portal = resolved
|
||||
locale = str(request.query_params.get("locale", portal.default_locale))
|
||||
with tenant_atomic(context):
|
||||
@@ -121,7 +122,7 @@ class PublicArticleDetailView(PublicPortalView):
|
||||
slug=article_slug
|
||||
).first()
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
return Response({"article": public_article_payload(article)})
|
||||
|
||||
|
||||
@@ -132,10 +133,10 @@ class PublicArticleFeedbackView(PublicPortalView):
|
||||
def post(self, request: Request, article_slug: str) -> Response:
|
||||
resolved = self.resolve(request)
|
||||
if resolved is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
helpful = request.data.get("helpful")
|
||||
if not isinstance(helpful, bool):
|
||||
return Response({"detail": "helpful должен быть boolean"}, status=400)
|
||||
return Response({"detail": t("portals.helpful_must_be_boolean")}, status=400)
|
||||
context, portal = resolved
|
||||
locale = str(request.query_params.get("locale", portal.default_locale))
|
||||
with tenant_atomic(context):
|
||||
@@ -143,6 +144,6 @@ class PublicArticleFeedbackView(PublicPortalView):
|
||||
slug=article_slug
|
||||
).first()
|
||||
if article is None:
|
||||
return Response({"detail": "Статья не найдена"}, status=404)
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
record_feedback(article, helpful)
|
||||
return Response({"ok": True}, status=201)
|
||||
@@ -16,6 +16,7 @@ from django.core.files import File
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.events.handlers import register
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy import storage_settings as ss
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -38,7 +39,7 @@ def _local_keys(root: Path) -> list[str]:
|
||||
def migrate_local_to_s3(row: ss.StorageSettings, *, batch_progress: int = 25) -> int:
|
||||
config = ss.config_from_settings(row)
|
||||
if not config.s3_configured:
|
||||
raise ValueError("S3 не настроено")
|
||||
raise ValueError(t("settings.s3_not_configured"))
|
||||
s3 = ss.build_s3_storage(config)
|
||||
root = Path(settings.MEDIA_ROOT)
|
||||
keys = _local_keys(root)
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.crypto import EncryptedCharField
|
||||
|
||||
|
||||
@@ -158,7 +159,7 @@ def build_s3_storage(config: StorageConfig, *, probe: bool = False):
|
||||
from botocore.config import Config
|
||||
from storages.backends.s3 import S3Storage
|
||||
except ImportError as error: # pragma: no cover - deployment guard
|
||||
raise ValidationError("django-storages[s3] не установлен") from error
|
||||
raise ValidationError(t("settings.storages_missing")) from error
|
||||
client_config = (
|
||||
Config(connect_timeout=5, read_timeout=10, retries={"max_attempts": 1})
|
||||
if probe
|
||||
|
||||
@@ -18,6 +18,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.tenancy import storage_settings as ss
|
||||
|
||||
@@ -91,7 +92,7 @@ class StorageSettingsView(APIView):
|
||||
body = request.data
|
||||
backend = str(body.get("backend") or row.backend).upper()
|
||||
if backend not in ss.StorageBackend.values:
|
||||
return Response({"detail": "Неизвестный тип хранилища", "errors": {"backend": "LOCAL или S3"}}, status=400)
|
||||
return Response({"detail": t("settings.unknown_storage_kind"), "errors": {"backend": "LOCAL или S3"}}, status=400)
|
||||
config = _apply_fields(row, body)
|
||||
if backend == ss.StorageBackend.S3:
|
||||
try:
|
||||
@@ -150,9 +151,9 @@ class StorageMigrateView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
row = ss.StorageSettings.load()
|
||||
if row.backend != ss.StorageBackend.S3 or not row.s3_configured:
|
||||
return Response({"detail": "Сначала включите и сохраните S3-хранилище"}, status=400)
|
||||
return Response({"detail": t("settings.enable_s3_first")}, status=400)
|
||||
if row.migration_status == ss.StorageMigrationStatus.RUNNING:
|
||||
return Response({"detail": "Перенос уже идёт"}, status=409)
|
||||
return Response({"detail": t("settings.migration_running")}, status=409)
|
||||
row.migration_status = ss.StorageMigrationStatus.RUNNING
|
||||
row.migration_total = 0
|
||||
row.migration_done = 0
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,7 @@ from chatballs.conversations.attachment_views import (
|
||||
)
|
||||
from chatballs.conversations.models import Message, MessageKind
|
||||
from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.features import voice_messages_allowed
|
||||
from chatballs.integrations.models import IntegrationStatus
|
||||
@@ -136,15 +137,15 @@ class WebchatSessionView(_Public):
|
||||
channel_code = str(request.data.get("channel", ""))
|
||||
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
|
||||
if context is None or widget is None:
|
||||
return Response({"detail": "Виджет недоступен"}, status=404)
|
||||
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
|
||||
if (
|
||||
not widget.integration.channel.allow_anonymous_sessions
|
||||
or not services.origin_allowed(widget, host_origin(request))
|
||||
):
|
||||
return Response({"detail": "Виджет недоступен"}, status=404)
|
||||
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
|
||||
result = services.issue_session(context=context, widget=widget)
|
||||
if result is None:
|
||||
return Response({"detail": "Виджет недоступен"}, status=404)
|
||||
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
|
||||
return Response(result, status=201)
|
||||
|
||||
|
||||
@@ -155,17 +156,17 @@ class WebchatMessagesView(_PublicSession):
|
||||
def post(self, request: Request) -> Response:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
return Response({"detail": t("webchat.session_not_found")}, status=401)
|
||||
upload = request.FILES.get("audio")
|
||||
if upload is not None:
|
||||
# Голосовое из виджета (дизайн-базлайн v2, кадр H).
|
||||
if not voice_messages_allowed(session.connection):
|
||||
return Response({"detail": "Голосовые отключены"}, status=400)
|
||||
return Response({"detail": t("webchat.voice_off")}, status=400)
|
||||
if upload.size > MAX_VOICE_BYTES:
|
||||
return Response({"detail": "Аудио больше 10 МБ"}, status=400)
|
||||
return Response({"detail": t("conversations.audio_too_large")}, status=400)
|
||||
content_type = (upload.content_type or "audio/webm").split(";")[0]
|
||||
if content_type not in ALLOWED_AUDIO_TYPES:
|
||||
return Response({"detail": "Неподдерживаемый формат аудио"}, status=400)
|
||||
return Response({"detail": t("conversations.audio_format_unsupported")}, status=400)
|
||||
try:
|
||||
duration = max(0, int(request.data.get("duration", 0)))
|
||||
except (TypeError, ValueError):
|
||||
@@ -192,14 +193,14 @@ class WebchatMessagesView(_PublicSession):
|
||||
return Response({"ok": True}, status=201)
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": "Пустое сообщение"}, status=400)
|
||||
return Response({"detail": t("ai.empty_message")}, status=400)
|
||||
services.post_message(session, text[:4000])
|
||||
return Response({"ok": True}, status=201)
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
return Response({"detail": t("webchat.session_not_found")}, status=401)
|
||||
try:
|
||||
since = int(request.GET.get("since", "0") or 0)
|
||||
except ValueError:
|
||||
@@ -211,7 +212,7 @@ class WebchatMessageAudioView(_PublicSession):
|
||||
def get(self, request: Request, message_id: int) -> Response | FileResponse:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
return Response({"detail": t("webchat.session_not_found")}, status=401)
|
||||
message = (
|
||||
Message.objects.filter(
|
||||
id=message_id,
|
||||
@@ -223,7 +224,7 @@ class WebchatMessageAudioView(_PublicSession):
|
||||
.first()
|
||||
)
|
||||
if message is None:
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
return Response({"detail": t("conversations.message_not_found")}, status=404)
|
||||
response = FileResponse(
|
||||
message.audio.open("rb"),
|
||||
content_type=message.audio_content_type or "audio/ogg",
|
||||
@@ -236,7 +237,7 @@ class WebchatMessageAttachmentView(_PublicSession):
|
||||
def get(self, request: Request, message_id: int) -> Response | FileResponse:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
return Response({"detail": t("webchat.session_not_found")}, status=401)
|
||||
message = (
|
||||
Message.objects.filter(
|
||||
id=message_id,
|
||||
@@ -248,7 +249,7 @@ class WebchatMessageAttachmentView(_PublicSession):
|
||||
.first()
|
||||
)
|
||||
if message is None:
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
return Response({"detail": t("conversations.message_not_found")}, status=404)
|
||||
response = attachment_response(message, inline="inline" in request.GET)
|
||||
response["Cache-Control"] = "private, max-age=3600"
|
||||
return response
|
||||
@@ -258,10 +259,10 @@ class WebchatContactView(_PublicSession):
|
||||
def post(self, request: Request) -> Response:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
return Response({"detail": t("webchat.session_not_found")}, status=401)
|
||||
phone = services.normalize_phone(str(request.data.get("phone", "")))
|
||||
if not phone:
|
||||
return Response({"detail": "Некорректный номер телефона"}, status=400)
|
||||
return Response({"detail": t("webchat.invalid_phone")}, status=400)
|
||||
services.post_contact(session, phone)
|
||||
return Response({"ok": True}, status=201)
|
||||
|
||||
@@ -274,11 +275,11 @@ class WebchatCallOpenView(_PublicSession):
|
||||
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
return Response({"detail": t("webchat.session_not_found")}, status=401)
|
||||
try:
|
||||
resolved = open_call_for_identity(identity=session.identity)
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Активное приглашение не найдено"}, status=404)
|
||||
return Response({"detail": t("webchat.no_active_invite")}, status=404)
|
||||
response = Response(
|
||||
{
|
||||
"call": public_invite_payload(resolved.invite.call_session, resolved.invite.expires_at),
|
||||
@@ -297,11 +298,11 @@ class WebchatCallDeclineView(_PublicSession):
|
||||
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
return Response({"detail": t("webchat.session_not_found")}, status=401)
|
||||
try:
|
||||
decline_call_for_identity(identity=session.identity)
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Активное приглашение не найдено"}, status=404)
|
||||
return Response({"detail": t("webchat.no_active_invite")}, status=404)
|
||||
except CallConflict as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
return Response({"ok": True})
|
||||
|
||||
@@ -5,6 +5,8 @@ from pathlib import Path
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from chatballs.i18n.languages import DEFAULT_LANGUAGE
|
||||
from chatballs.i18n.languages import LANGUAGES as CHATBALLS_LANGUAGES
|
||||
from chatballs_backend.settings_database import build_databases
|
||||
from chatballs_backend.settings_env import env_bool, env_list, env_secret
|
||||
from chatballs_backend.settings_storage import build_storage_settings
|
||||
@@ -85,6 +87,9 @@ MIDDLEWARE = [
|
||||
"chatballs.identity.middleware.SessionActivityMiddleware",
|
||||
"chatballs.events.middleware.CorrelationIdMiddleware",
|
||||
"chatballs.tenancy.middleware.TenantContextMiddleware",
|
||||
# Последней в цепочке — ближе всех к вьюхе: язык организации известен
|
||||
# только после того, как TenantContextMiddleware её определил.
|
||||
"chatballs.i18n.middleware.LanguageMiddleware",
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
@@ -141,7 +146,13 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "chatballs.identity.password_validation.PasswordComplexityValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "ru-ru"
|
||||
# Язык — настройка продукта, а не окружения: его выбирает владелец в
|
||||
# «Настройках» и каждый сотрудник в профиле, а LANGUAGE_CODE остаётся только
|
||||
# последним рубежом для кода, который выполняется вне запроса (команды,
|
||||
# фоновые задачи). Список языков общий с каталогом chatballs.i18n, чтобы
|
||||
# добавление языка не требовало править настройки в двух местах.
|
||||
LANGUAGE_CODE = DEFAULT_LANGUAGE
|
||||
LANGUAGES = CHATBALLS_LANGUAGES
|
||||
TIME_ZONE = "Europe/Moscow"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ConfigProvider } from "antd";
|
||||
import enUS from "antd/locale/en_US";
|
||||
import ruRU from "antd/locale/ru_RU";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { buildTheme } from "@chatballs/ui";
|
||||
|
||||
import { applyAppearance, DEFAULT_ACCENT, resolvedDark } from "./shared/appearance";
|
||||
|
||||
import { acceptServerLanguage, language } from "./i18n";
|
||||
import { api, setActiveOrganization } from "./api/client";
|
||||
import { fetchAgentDirectory } from "./features/agents/model";
|
||||
import { canAccess, defaultRoute, isManager } from "./auth/access";
|
||||
@@ -30,6 +33,10 @@ export function App() {
|
||||
() => buildTheme(resolvedDark(appearanceTheme), appearanceAccent),
|
||||
[appearanceTheme, appearanceAccent],
|
||||
);
|
||||
// Свои строки antd — «Нет данных», подписи пагинации, календарь — берёт из
|
||||
// собственных каталогов, и без locale остаётся английским посреди русского
|
||||
// экрана. Язык здесь уже окончательный: смена приходит перезагрузкой.
|
||||
const antdLocale = language() === "en" ? enUS : ruRU;
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [organizationPublicId, setOrganizationPublicId] = useState<string | null>(initialRoute.organizationPublicId);
|
||||
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
|
||||
@@ -43,6 +50,10 @@ export function App() {
|
||||
const { navigate } = navigation;
|
||||
|
||||
const useIdentity = useCallback((nextIdentity: AuthenticatedUser, requestedId: string | null) => {
|
||||
// Язык приходит уже разрешённым сервером. Если он разошёлся с тем, на
|
||||
// котором отрисован экран, страница перезагрузится — иначе половина
|
||||
// подписей осталась бы на прежнем языке (см. комментарий в src/i18n).
|
||||
acceptServerLanguage(nextIdentity.language);
|
||||
const activeUser = activateOrganization(nextIdentity, requestedId);
|
||||
setIdentity(nextIdentity);
|
||||
setUser(activeUser);
|
||||
@@ -72,8 +83,11 @@ export function App() {
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
api<{ authenticated: boolean; user?: AuthenticatedUser }>("/api/v1/auth/session/")
|
||||
api<{ authenticated: boolean; user?: AuthenticatedUser; language?: string }>("/api/v1/auth/session/")
|
||||
.then((payload) => {
|
||||
// Язык установки приходит и для гостя: экран входа принадлежит коробке,
|
||||
// и на английской машине он должен открываться на языке установки.
|
||||
if (!payload.authenticated) acceptServerLanguage(payload.language);
|
||||
if (payload.authenticated && payload.user) {
|
||||
const activeUser = useIdentity(payload.user, initialRoute.organizationPublicId);
|
||||
if (activeUser && !initialRoute.organizationPublicId) {
|
||||
@@ -133,16 +147,16 @@ export function App() {
|
||||
|
||||
if (resetting) {
|
||||
return (
|
||||
<ConfigProvider theme={antdTheme}>
|
||||
<ConfigProvider theme={antdTheme} locale={antdLocale}>
|
||||
<AuthResetPassword onDone={() => { setResetting(false); window.history.replaceState({}, "", pathFromRoute("chat")); }} />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionLoading) return <ConfigProvider theme={antdTheme}><LoadingScreen /></ConfigProvider>;
|
||||
if (sessionLoading) return <ConfigProvider theme={antdTheme} locale={antdLocale}><LoadingScreen /></ConfigProvider>;
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdTheme}>
|
||||
<ConfigProvider theme={antdTheme} locale={antdLocale}>
|
||||
{totpChallenge ? (
|
||||
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
|
||||
) : !identity ? (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from "../i18n";
|
||||
|
||||
const configuredApiBase = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
||||
let activeOrganizationPublicId: string | null = null;
|
||||
|
||||
@@ -37,7 +39,7 @@ export class ApiError<TPayload extends { detail?: string } = ApiErrorPayload> ex
|
||||
readonly payload: TPayload;
|
||||
|
||||
constructor(status: number, payload: TPayload) {
|
||||
super(payload.detail ?? "Ошибка запроса");
|
||||
super(payload.detail ?? t("common.request_failed"));
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
@@ -101,7 +103,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ detail: "Ошибка запроса" })) as ApiErrorPayload;
|
||||
const payload = await response.json().catch(() => ({ detail: t("common.request_failed") })) as ApiErrorPayload;
|
||||
throw new ApiError(response.status, payload);
|
||||
}
|
||||
if (response.status === 204 || response.status === 205) return undefined as T;
|
||||
@@ -123,7 +125,7 @@ export function apiUpload<T>(path: string, form: FormData, onProgress?: (percent
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
}
|
||||
request.onerror = () => reject(new ApiError(0, { detail: "Ошибка сети" }));
|
||||
request.onerror = () => reject(new ApiError(0, { detail: t("shared.network_error") }));
|
||||
request.onload = () => {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
@@ -135,7 +137,7 @@ export function apiUpload<T>(path: string, form: FormData, onProgress?: (percent
|
||||
resolve(payload as T);
|
||||
return;
|
||||
}
|
||||
reject(new ApiError(request.status, (payload ?? { detail: "Ошибка запроса" }) as ApiErrorPayload));
|
||||
reject(new ApiError(request.status, (payload ?? { detail: t("common.request_failed") }) as ApiErrorPayload));
|
||||
};
|
||||
request.send(form);
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
|
||||
totpEnabled: false,
|
||||
totpLastUsedAt: null,
|
||||
deliveryMode: "CLOUD",
|
||||
uiTheme: "SYSTEM",
|
||||
uiAccent: "",
|
||||
uiTheme: "SYSTEM",
|
||||
uiAccent: "",
|
||||
uiLanguage: "",
|
||||
language: "ru",
|
||||
memberships: [],
|
||||
organizationPublicId: "org-1",
|
||||
organization: "org",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AuditPeriod,
|
||||
type AuditQuery,
|
||||
} from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Фильтры журнала: поиск · период · раздел · сотрудник · результат · «Сбросить».
|
||||
// Те же контролы, что у списков Сотрудников и Контактов — SearchInput,
|
||||
@@ -27,20 +28,20 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
|
||||
const [actorOpen, setActorOpen] = useState(false);
|
||||
|
||||
const periodLabel = AUDIT_PERIODS.find(([value]) => value === query.period)?.[1] ?? "";
|
||||
const categoryLabel = categories.find((item) => item.value === query.category)?.label ?? "Все";
|
||||
const actorLabel = actors.find((item) => item.value === query.actor)?.label ?? "Все";
|
||||
const categoryLabel = categories.find((item) => item.value === query.category)?.label ?? t("common.all");
|
||||
const actorLabel = actors.find((item) => item.value === query.actor)?.label ?? t("common.all");
|
||||
|
||||
return (
|
||||
<div className="audit-filters">
|
||||
<SearchInput
|
||||
className="audit-search"
|
||||
hotkey="/"
|
||||
placeholder="Поиск по действию, объекту или сотруднику…"
|
||||
placeholder={t("admin.search_by_action_object_or")}
|
||||
value={query.q}
|
||||
onChange={(value) => setQuery({ q: value })}
|
||||
/>
|
||||
<FilterDropdown
|
||||
caption="Период"
|
||||
caption={t("admin.period")}
|
||||
label={periodLabel}
|
||||
open={periodOpen}
|
||||
options={AUDIT_PERIODS.map(([value, label]) => ({ value, label }))}
|
||||
@@ -49,27 +50,27 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
|
||||
onSelect={(value) => setQuery({ period: value as AuditPeriod })}
|
||||
/>
|
||||
<FilterDropdown
|
||||
caption="Раздел"
|
||||
caption={t("admin.section")}
|
||||
label={categoryLabel}
|
||||
open={categoryOpen}
|
||||
options={[{ value: "", label: "Все" }, ...categories]}
|
||||
options={[{ value: "", label: t("common.all") }, ...categories]}
|
||||
selected={query.category ? [query.category] : []}
|
||||
onOpenChange={setCategoryOpen}
|
||||
onSelect={(value) => setQuery({ category: value })}
|
||||
/>
|
||||
{actors.length > 1 && (
|
||||
<FilterDropdown
|
||||
caption="Сотрудник"
|
||||
caption={t("common.operator")}
|
||||
label={actorLabel}
|
||||
open={actorOpen}
|
||||
options={[{ value: "", label: "Все" }, ...actors]}
|
||||
options={[{ value: "", label: t("common.all") }, ...actors]}
|
||||
selected={query.actor ? [query.actor] : []}
|
||||
onOpenChange={setActorOpen}
|
||||
onSelect={(value) => setQuery({ actor: value })}
|
||||
/>
|
||||
)}
|
||||
<Segmented
|
||||
items={[["", "Все"], ["SUCCESS", "Выполнено"], ["DENIED", "Отклонено"], ["FAILED", "Ошибка"]]}
|
||||
items={[["", t("common.all")], ["SUCCESS", t("admin.succeeded")], ["DENIED", t("admin.rejected")], ["FAILED", t("common.error")]]}
|
||||
value={query.result}
|
||||
setValue={(value) => setQuery({ result: value })}
|
||||
/>
|
||||
@@ -78,9 +79,7 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
|
||||
className={`audit-reset ${auditQueryIsDirty(query) ? "is-dirty" : ""}`}
|
||||
type="button"
|
||||
onClick={reset}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
>{t("common.reset")}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { EmptyState, LoadingState, PageHeader } from "../../shared/ui";
|
||||
import { Pagination } from "../../shared/Pagination";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { pluralRu } from "../../shared/utils";
|
||||
import { AuditFilters } from "./AuditFilters";
|
||||
import { AuditTable } from "./AuditTable";
|
||||
import { auditQueryIsDirty } from "./model";
|
||||
import { useAudit } from "./useAudit";
|
||||
import { t, tn } from "../../i18n";
|
||||
|
||||
// «Аудит действий» — отдельный экран из субменю «Настроек». Журнал отдавался
|
||||
// последними 50 событиями без фильтров и без страниц: дальше пятидесятого
|
||||
@@ -18,10 +18,10 @@ export function AuditPage() {
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
title="Аудит"
|
||||
title={t("common.audit")}
|
||||
text={payload
|
||||
? `Журнал действий в организации · ${pluralRu(payload.total, ["событие", "события", "событий"])}`
|
||||
: "Журнал действий в организации"}
|
||||
? t("admin.audit_log_with_total", { total: tn("plural.events", payload.total) })
|
||||
: t("admin.log_actions_organization")}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AuditPage() {
|
||||
<div className="audit-page">
|
||||
{header}
|
||||
<EmptyState title={error} />
|
||||
<Button variant="secondary" onClick={() => void audit.reload()}>Повторить</Button>
|
||||
<Button variant="secondary" onClick={() => void audit.reload()}>{t("common.try_again")}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,13 +56,13 @@ export function AuditPage() {
|
||||
таблица моргает пустотой на каждый ввод в поиске. */}
|
||||
<div className={`audit-results ${loading ? "is-loading" : ""}`}>
|
||||
{payload.items.length === 0
|
||||
? <EmptyState title={dirty ? "Под фильтры ничего не подошло" : "Событий пока нет"} />
|
||||
? <EmptyState title={dirty ? t("admin.nothing_matched_filters") : t("admin.no_events_yet")} />
|
||||
: <AuditTable events={payload.items} />}
|
||||
</div>
|
||||
{payload.pageCount > 1 && (
|
||||
<Pagination
|
||||
className="audit-pagination"
|
||||
note={`Показано ${payload.items.length} из ${payload.total} · по ${payload.pageSize} на странице`}
|
||||
note={t("common.shown_of_page", { shown: payload.items.length, total: payload.total, size: payload.pageSize })}
|
||||
page={payload.page}
|
||||
pageCount={payload.pageCount}
|
||||
onPage={(page) => setQuery({ page })}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Fragment, useState } from "react";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { shortDate, shortDateYear } from "../../shared/utils";
|
||||
import type { AuditEvent } from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Журнал действий. Три вещи, без которых он был нечитаем:
|
||||
// * день отбивается заголовком — иначе сотни строк идут сплошняком;
|
||||
@@ -26,16 +27,16 @@ function dayLabel(value: string): string {
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
if (dayKey(value) === dayKey(today.toISOString())) return "Сегодня";
|
||||
if (dayKey(value) === dayKey(yesterday.toISOString())) return "Вчера";
|
||||
if (dayKey(value) === dayKey(today.toISOString())) return t("common.today");
|
||||
if (dayKey(value) === dayKey(yesterday.toISOString())) return t("admin.yesterday");
|
||||
return date.getFullYear() === today.getFullYear() ? shortDate(date) : shortDateYear(date);
|
||||
}
|
||||
|
||||
function detailLines(event: AuditEvent): Array<[string, string]> {
|
||||
const lines: Array<[string, string]> = [];
|
||||
if (event.actorEmail) lines.push(["Сотрудник", `${event.actor} · ${event.actorEmail}`]);
|
||||
lines.push(["Код действия", event.action]);
|
||||
if (event.objectType) lines.push(["Объект", `${event.objectType}${event.objectId ? ` · ${event.objectId}` : ""}`]);
|
||||
if (event.actorEmail) lines.push([t("common.operator"), `${event.actor} · ${event.actorEmail}`]);
|
||||
lines.push([t("admin.action_code"), event.action]);
|
||||
if (event.objectType) lines.push([t("common.object"), `${event.objectType}${event.objectId ? ` · ${event.objectId}` : ""}`]);
|
||||
if (event.sourceIp) lines.push(["IP", event.sourceIp]);
|
||||
if (event.correlationId) lines.push(["Correlation id", event.correlationId]);
|
||||
Object.entries(event.details ?? {}).forEach(([key, value]) => {
|
||||
@@ -53,11 +54,11 @@ export function AuditTable({ events }: { events: AuditEvent[] }) {
|
||||
<table className="baseline-table audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="audit-col-time">Время</th>
|
||||
<th className="audit-col-actor">Сотрудник</th>
|
||||
<th>Действие</th>
|
||||
<th className="audit-col-object">Объект</th>
|
||||
<th className="audit-col-result">Результат</th>
|
||||
<th className="audit-col-time">{t("admin.time")}</th>
|
||||
<th className="audit-col-actor">{t("common.operator")}</th>
|
||||
<th>{t("common.action")}</th>
|
||||
<th className="audit-col-object">{t("common.object")}</th>
|
||||
<th className="audit-col-result">{t("admin.result")}</th>
|
||||
<th className="audit-col-toggle" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
|
||||
|
||||
import { DecisionDialog } from "../../shared/DecisionDialog";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export function OrganizationLogoField({
|
||||
logoUrl,
|
||||
@@ -25,12 +26,12 @@ export function OrganizationLogoField({
|
||||
<div className="administration-logo-field">
|
||||
<div className="administration-logo-preview">
|
||||
{logoUrl
|
||||
? <img src={logoUrl} alt={`Логотип ${name}`} />
|
||||
? <img src={logoUrl} alt={t("admin.organization_logo_alt", { name })} />
|
||||
: <span>{name.trim().slice(0, 2).toUpperCase() || "CR"}</span>}
|
||||
</div>
|
||||
<div className="administration-logo-copy">
|
||||
<strong>Логотип</strong>
|
||||
<span>PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах.</span>
|
||||
<strong>{t("admin.logo")}</strong>
|
||||
<span>{t("admin.png_jpeg_or_webp_up")}</span>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="administration-logo-actions">
|
||||
@@ -49,17 +50,13 @@ export function OrganizationLogoField({
|
||||
variant="secondary"
|
||||
disabled={saving}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
Загрузить
|
||||
</Button>
|
||||
>{t("common.upload")}</Button>
|
||||
<button
|
||||
className="administration-logo-remove"
|
||||
type="button"
|
||||
disabled={saving || !logoUrl}
|
||||
onClick={() => setConfirmingRemoval(true)}
|
||||
>
|
||||
Убрать
|
||||
</button>
|
||||
>{t("common.remove")}</button>
|
||||
</div>
|
||||
)}
|
||||
<DecisionDialog
|
||||
@@ -67,13 +64,11 @@ export function OrganizationLogoField({
|
||||
onClose={() => setConfirmingRemoval(false)}
|
||||
tone="danger"
|
||||
icon="trash"
|
||||
title="Удалить логотип?"
|
||||
description="Вместо него в меню снова будет показан знак Chatballs."
|
||||
title={t("admin.remove_logo")}
|
||||
description={t("admin.chatballs_mark_will_shown_menu")}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmingRemoval(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setConfirmingRemoval(false)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
disabled={saving}
|
||||
@@ -81,9 +76,7 @@ export function OrganizationLogoField({
|
||||
setConfirmingRemoval(false);
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
>{t("common.delete")}</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { FormEvent } from "react";
|
||||
import { FormField, SelectField } from "../../shared/form-controls";
|
||||
import { shortDateTime, timezoneLabel } from "../../shared/utils";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import type { OrganizationLanguageOption } from "./api";
|
||||
import type { OrganizationSettings } from "./model";
|
||||
import { OrganizationLogoField } from "./OrganizationLogoField";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
function savedLabel(updatedAt: string | null): string {
|
||||
if (!updatedAt) return "Ещё не сохранялось";
|
||||
return `Сохранено ${shortDateTime(updatedAt)}`;
|
||||
if (!updatedAt) return t("common.never_saved_yet");
|
||||
return t("time.saved_at", { time: shortDateTime(updatedAt) });
|
||||
}
|
||||
|
||||
export function OrganizationSettingsForm({
|
||||
@@ -18,6 +20,7 @@ export function OrganizationSettingsForm({
|
||||
message,
|
||||
error,
|
||||
timezones,
|
||||
languages,
|
||||
onChange,
|
||||
onSave,
|
||||
onUploadLogo,
|
||||
@@ -29,6 +32,7 @@ export function OrganizationSettingsForm({
|
||||
message: string;
|
||||
error: string;
|
||||
timezones: string[];
|
||||
languages: OrganizationLanguageOption[];
|
||||
onChange: (next: OrganizationSettings) => void;
|
||||
onSave: () => void;
|
||||
onUploadLogo: (file: File) => void;
|
||||
@@ -52,7 +56,7 @@ export function OrganizationSettingsForm({
|
||||
<div className="administration-fields">
|
||||
<div className="administration-field-wide">
|
||||
<FormField
|
||||
label="Название организации"
|
||||
label={t("admin.organization_name")}
|
||||
value={organization.name}
|
||||
disabled={!canManage}
|
||||
onChange={canManage
|
||||
@@ -61,18 +65,31 @@ export function OrganizationSettingsForm({
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Часовой пояс"
|
||||
label={t("admin.time_zone")}
|
||||
value={organization.timezone}
|
||||
disabled={!canManage}
|
||||
onChange={(timezone) => onChange({ ...organization, timezone })}
|
||||
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
|
||||
/>
|
||||
<SelectField
|
||||
label="Валюта"
|
||||
label={t("settings.language")}
|
||||
value={organization.language}
|
||||
disabled={!canManage}
|
||||
onChange={(language) => onChange({ ...organization, language })}
|
||||
// Первый пункт — не язык, а отказ от выбора: организация без своего
|
||||
// языка следует за установкой, и владелец видит это словами.
|
||||
options={[
|
||||
["", t("settings.language_as_installation")],
|
||||
...languages.map((item): [string, string] => [item.code, item.label]),
|
||||
]}
|
||||
hint={t("settings.language_org_hint")}
|
||||
/>
|
||||
<SelectField
|
||||
label={t("admin.currency")}
|
||||
value={organization.currency}
|
||||
disabled={!canManage}
|
||||
onChange={(currency) => onChange({ ...organization, currency })}
|
||||
options={[["RUB", "Российский рубль (RUB)"]]}
|
||||
options={[["RUB", t("admin.russian_rouble_rub")]]}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="administration-message error" role="alert">{error}</div>}
|
||||
@@ -85,7 +102,7 @@ export function OrganizationSettingsForm({
|
||||
variant="primary"
|
||||
disabled={saving || !organization.name.trim()}
|
||||
>
|
||||
{saving ? "Сохранение" : "Сохранить"}
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,11 +3,18 @@ import type { AuditPayload, AuditQuery, OrganizationSettings } from "./model";
|
||||
|
||||
const BASE = "/api/v1/company/administration/";
|
||||
|
||||
export type OrganizationLanguageOption = { code: string; label: string };
|
||||
|
||||
export async function loadOrganizationSettings(): Promise<{
|
||||
organization: OrganizationSettings;
|
||||
timezones: string[];
|
||||
languages: OrganizationLanguageOption[];
|
||||
}> {
|
||||
return api<{ organization: OrganizationSettings; timezones: string[] }>(BASE);
|
||||
return api<{
|
||||
organization: OrganizationSettings;
|
||||
timezones: string[];
|
||||
languages: OrganizationLanguageOption[];
|
||||
}>(BASE);
|
||||
}
|
||||
|
||||
export async function saveOrganizationSettings(
|
||||
@@ -19,6 +26,7 @@ export async function saveOrganizationSettings(
|
||||
name: settings.name,
|
||||
timezone: settings.timezone,
|
||||
currency: settings.currency,
|
||||
language: settings.language,
|
||||
}),
|
||||
});
|
||||
return payload.organization;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionUser } from "../../types";
|
||||
import { hasCapability } from "../../auth/access";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Аудит живёт на своём экране (AuditPage) и своём хуке; useAdministration
|
||||
// остался только под «Организацию» в Настройках.
|
||||
@@ -9,6 +10,8 @@ export type OrganizationSettings = {
|
||||
name: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
// Пустая строка — «как в установке»: организация не обязана выбирать язык.
|
||||
language: string;
|
||||
logoUrl: string | null;
|
||||
// Когда настройки сохраняли в последний раз — подпись у кнопки (кадр N1).
|
||||
updatedAt: string | null;
|
||||
@@ -65,11 +68,11 @@ export type AuditPayload = {
|
||||
};
|
||||
|
||||
export const AUDIT_PERIODS: Array<[AuditPeriod, string]> = [
|
||||
["today", "Сегодня"],
|
||||
["7d", "7 дней"],
|
||||
["30d", "30 дней"],
|
||||
["90d", "90 дней"],
|
||||
["all", "Всё время"],
|
||||
["today", t("common.today")],
|
||||
["7d", t("admin.7_days")],
|
||||
["30d", t("admin.30_days")],
|
||||
["90d", t("admin.90_days")],
|
||||
["all", t("admin.all_time")],
|
||||
];
|
||||
|
||||
export const EMPTY_AUDIT_QUERY: AuditQuery = {
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
saveOrganizationSettings,
|
||||
uploadOrganizationLogo,
|
||||
} from "./api";
|
||||
import type { OrganizationLanguageOption } from "./api";
|
||||
import type { AdministrationSection, OrganizationSettings } from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export function useAdministration({
|
||||
section,
|
||||
@@ -21,6 +23,7 @@ export function useAdministration({
|
||||
}) {
|
||||
const [organization, setOrganization] = useState<OrganizationSettings | null>(null);
|
||||
const [timezones, setTimezones] = useState<string[]>([]);
|
||||
const [languages, setLanguages] = useState<OrganizationLanguageOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -40,8 +43,9 @@ export function useAdministration({
|
||||
const settings = await loadOrganizationSettings();
|
||||
setOrganization(settings.organization);
|
||||
setTimezones(settings.timezones);
|
||||
setLanguages(settings.languages);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof ApiError ? loadError.message : "Не удалось загрузить данные");
|
||||
setError(loadError instanceof ApiError ? loadError.message : t("admin.could_not_load_data"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -64,7 +68,7 @@ export function useAdministration({
|
||||
setMessage(successMessage);
|
||||
await refreshIdentity();
|
||||
} catch (changeError) {
|
||||
setError(changeError instanceof ApiError ? changeError.message : "Не удалось сохранить изменения");
|
||||
setError(changeError instanceof ApiError ? changeError.message : t("admin.could_not_save_changes"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -77,19 +81,20 @@ export function useAdministration({
|
||||
organization,
|
||||
saving,
|
||||
timezones,
|
||||
languages,
|
||||
setOrganization,
|
||||
reload: load,
|
||||
save: () => organization && runChange(
|
||||
() => saveOrganizationSettings(organization),
|
||||
"Данные организации сохранены",
|
||||
t("admin.organization_details_saved"),
|
||||
),
|
||||
uploadLogo: (file: File) => runChange(
|
||||
() => uploadOrganizationLogo(file),
|
||||
"Логотип обновлён",
|
||||
t("admin.logo_updated"),
|
||||
),
|
||||
removeLogo: () => runChange(
|
||||
removeOrganizationLogo,
|
||||
"Логотип удалён",
|
||||
t("admin.logo_removed"),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useDebounced } from "../../shared/useDebounced";
|
||||
import { usePagedResource } from "../../shared/usePagedResource";
|
||||
import { loadAudit } from "./api";
|
||||
import { EMPTY_AUDIT_QUERY, type AuditPayload, type AuditQuery } from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Журнал аудита: фильтры и страница живут на сервере — журнал растёт без
|
||||
// предела, и отдать его целиком нельзя. Страницей, отложенным поиском и
|
||||
@@ -27,7 +28,7 @@ export function useAudit() {
|
||||
(page: number) => loadAudit({ ...filters, page }),
|
||||
[filters],
|
||||
);
|
||||
const journal = usePagedResource<AuditPayload>(load, filters, "Не удалось загрузить журнал");
|
||||
const journal = usePagedResource<AuditPayload>(load, filters, t("admin.could_not_load_log"));
|
||||
|
||||
const setQuery = useCallback((patch: Partial<AuditQuery>) => {
|
||||
// Страницу двигает подвал списка, фильтры — состояние запроса; смена
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type AgentConnection,
|
||||
type AgentPatch,
|
||||
} from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Карточка агента (дизайн-базлайн v2, «Агенты Baseline», кадры G3–G5): слева —
|
||||
// что агент знает и как говорит (инструкции, знания), справа — как он работает
|
||||
@@ -118,7 +119,7 @@ export function AgentDetailPage({
|
||||
if (canManageConnections) void loadMessengers();
|
||||
}, [canManageConnections, loadMessengers]);
|
||||
|
||||
if (missing) return <EmptyState title="Агент не найден" />;
|
||||
if (missing) return <EmptyState title={t("ai.agent_not_found")} />;
|
||||
if (failed) return <ErrorScreen retry={() => void reload()} />;
|
||||
if (!card) return <LoadingState />;
|
||||
|
||||
@@ -132,7 +133,7 @@ export function AgentDetailPage({
|
||||
} catch (caught) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
text: caught instanceof ApiError ? caught.payload.detail ?? "Не удалось сохранить" : "Не удалось сохранить",
|
||||
text: caught instanceof ApiError ? caught.payload.detail ?? t("common.could_not_save") : t("common.could_not_save"),
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
@@ -151,7 +152,7 @@ export function AgentDetailPage({
|
||||
kind: "error",
|
||||
text: caught instanceof ApiError && caught.payload.detail
|
||||
? caught.payload.detail
|
||||
: "Не удалось изменить статус AI",
|
||||
: t("ai.could_not_change_ai_status"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -169,8 +170,8 @@ export function AgentDetailPage({
|
||||
kind: "warning",
|
||||
text:
|
||||
caught instanceof ApiError && caught.status === 409
|
||||
? "Агента нельзя удалить: есть диалоги или подключения. Отвяжите подключения и закройте диалоги."
|
||||
: "Не удалось удалить агента",
|
||||
? t("ai.agent_cannot_deleted_has_conversations")
|
||||
: t("ai.could_not_delete_agent"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -184,7 +185,7 @@ export function AgentDetailPage({
|
||||
setCard(saved.agent);
|
||||
void loadMessengers();
|
||||
} catch {
|
||||
setFeedback({ kind: "error", text: "Не удалось отвязать подключение" });
|
||||
setFeedback({ kind: "error", text: t("ai.could_not_detach_connection") });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -202,8 +203,8 @@ export function AgentDetailPage({
|
||||
kind: "error",
|
||||
text:
|
||||
caught instanceof ApiError && caught.status === 409
|
||||
? "Подключение уже привязано к другому агенту"
|
||||
: "Не удалось привязать подключение",
|
||||
? t("ai.connection_already_bound_another_agent")
|
||||
: t("ai.could_not_bind_connection"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -216,14 +217,14 @@ export function AgentDetailPage({
|
||||
// Выключение агента и удаление живут в этом меню, а не отдельными кнопками
|
||||
// под карточкой: на странице остаётся один переключатель AI.
|
||||
const menuItems = [
|
||||
{ key: "copy-code", label: <button type="button" onClick={() => { setMenuOpen(false); void navigator.clipboard?.writeText(card.code); }}><Icon name="copy" size={15} />Скопировать код</button> },
|
||||
{ key: "copy-code", label: <button type="button" onClick={() => { setMenuOpen(false); void navigator.clipboard?.writeText(card.code); }}><Icon name="copy" size={15} />{t("ai.copy_snippet")}</button> },
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "active",
|
||||
disabled: busy,
|
||||
label: (
|
||||
<button type="button" onClick={() => { setMenuOpen(false); void apply({ isActive: !card.isActive }); }}>
|
||||
<Icon name={card.isActive ? "xCircle" : "check"} size={15} />{card.isActive ? "Выключить агента" : "Включить агента"}
|
||||
<Icon name={card.isActive ? "xCircle" : "check"} size={15} />{card.isActive ? t("ai.turn_agent_off") : t("ai.turn_agent")}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
@@ -232,15 +233,14 @@ export function AgentDetailPage({
|
||||
disabled: busy,
|
||||
label: (
|
||||
<button className="danger" type="button" onClick={() => { setMenuOpen(false); setDeleting(true); }}>
|
||||
<Icon name="trash" size={15} />Удалить агента
|
||||
</button>
|
||||
<Icon name="trash" size={15} />{t("ai.delete_agent")}</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="agent-page">
|
||||
<BackLink label="Агенты" onClick={openAgents} />
|
||||
<BackLink label={t("common.agents")} onClick={openAgents} />
|
||||
|
||||
<header className="agent-head">
|
||||
<span className="agent-head-tile" style={{ background: tile.background, color: tile.color }}>
|
||||
@@ -252,22 +252,22 @@ export function AgentDetailPage({
|
||||
<b className="agents-status" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
|
||||
</div>
|
||||
<p>
|
||||
<span><i style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />{card.groupName ?? "Без группы"}</span>
|
||||
<span><i style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />{card.groupName ?? t("common.no_group")}</span>
|
||||
<i />
|
||||
<code>{card.code}</code>
|
||||
<i />
|
||||
{openDialogsLine(card.counters.openConversations)}
|
||||
<i />
|
||||
создан {createdLabel(card.createdAt)}
|
||||
{t("ai.created_on", { date: createdLabel(card.createdAt) })}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="agent-head-actions">
|
||||
<Button variant="secondary" className="agent-toggle" icon={running ? "pause" : "bolt"} disabled={busy} onClick={() => void toggleAi()}>
|
||||
{running ? "Остановить AI" : "Запустить AI"}
|
||||
{running ? t("ai.stop_ai") : t("ai.start_ai")}
|
||||
</Button>
|
||||
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown">
|
||||
<button className="agent-head-menu" type="button" aria-label="Действия агента" title="Действия"><Icon name="more" size={17} strokeWidth={2} /></button>
|
||||
<button className="agent-head-menu" type="button" aria-label={t("ai.agent_actions")} title={t("common.actions")}><Icon name="more" size={17} strokeWidth={2} /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
@@ -277,11 +277,11 @@ export function AgentDetailPage({
|
||||
<div className="agent-blocker">
|
||||
<Icon name="alert" size={18} strokeWidth={2.2} />
|
||||
<div>
|
||||
<strong>AI не может отвечать: провайдер не выбран</strong>
|
||||
<small>Добавьте ключ в «Настройки → AI-провайдер» и выберите провайдера в блоке «Модель». До этого диалоги агента ждут человека.</small>
|
||||
<strong>{t("ai.ai_cannot_reply_no_provider")}</strong>
|
||||
<small>{t("ai.add_key_under_settings_ai")}</small>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button type="button" onClick={openAiProvider}>Открыть настройки<Icon name="external" size={13} strokeWidth={2.2} /></button>
|
||||
<button type="button" onClick={openAiProvider}>{t("ai.open_settings")}<Icon name="external" size={13} strokeWidth={2.2} /></button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -311,14 +311,14 @@ export function AgentDetailPage({
|
||||
{deleting && (
|
||||
<Modal
|
||||
open
|
||||
title="Удалить агента?"
|
||||
okText="Удалить"
|
||||
cancelText="Отмена"
|
||||
title={t("ai.delete_agent_2")}
|
||||
okText={t("common.delete")}
|
||||
cancelText={t("common.cancel")}
|
||||
okButtonProps={{ danger: true, disabled: busy }}
|
||||
onOk={() => void removeAgent()}
|
||||
onCancel={() => setDeleting(false)}
|
||||
>
|
||||
<p>Агент «{card.name}» будет удалён безвозвратно. Диалоги и подключения не дают удалить агента.</p>
|
||||
<p>{t("ai.agent_will_be_deleted", { name: card.name })}</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
@@ -328,9 +328,9 @@ export function AgentDetailPage({
|
||||
// --- Инструкции (кадры G3/G5) ---
|
||||
|
||||
const INSTRUCTION_FIELDS = [
|
||||
{ key: "persona", label: "Кто он и что делает", hint: "персонализация", rows: 7, placeholder: "" },
|
||||
{ key: "tone", label: "Как он должен говорить", hint: "тон", rows: 7, placeholder: "Например: спокойно и вежливо, на «вы»" },
|
||||
{ key: "instructions", label: "Правила работы", hint: "инструкции", rows: 10, placeholder: "Например: не называй цены, направляй к консультанту" },
|
||||
{ key: "persona", label: t("ai.who_what_does"), hint: t("ai.persona"), rows: 7, placeholder: "" },
|
||||
{ key: "tone", label: t("ai.how_should_speak"), hint: t("ai.tone"), rows: 7, placeholder: t("ai.example_calmly_politely_formal_address") },
|
||||
{ key: "instructions", label: t("ai.working_rules"), hint: t("ai.instructions"), rows: 10, placeholder: t("ai.example_do_not_quote_prices") },
|
||||
] as const;
|
||||
|
||||
function InstructionsCard({ card, canManage, busy, apply }: {
|
||||
@@ -348,8 +348,8 @@ function InstructionsCard({ card, canManage, busy, apply }: {
|
||||
return (
|
||||
<section className="agent-card">
|
||||
<div className="agent-card-head">
|
||||
<h3>Инструкции</h3>
|
||||
<small>Системный промпт собирается из трёх частей в этом порядке</small>
|
||||
<h3>{t("ai.instructions_2")}</h3>
|
||||
<small>{t("ai.system_prompt_assembled_from_three")}</small>
|
||||
</div>
|
||||
<div className="agent-instructions">
|
||||
{INSTRUCTION_FIELDS.map((field) => (
|
||||
@@ -369,9 +369,9 @@ function InstructionsCard({ card, canManage, busy, apply }: {
|
||||
</div>
|
||||
{canManage && dirty && (
|
||||
<div className="agent-dirty">
|
||||
<small>Есть несохранённые изменения — применятся в runtime сразу</small>
|
||||
<button type="button" disabled={busy} onClick={() => setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions })}>Отменить</button>
|
||||
<button className="is-primary" type="button" disabled={busy} onClick={() => void apply(draft)}>Сохранить</button>
|
||||
<small>{t("ai.there_unsaved_changes_they_take")}</small>
|
||||
<button type="button" disabled={busy} onClick={() => setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions })}>{t("ai.undo")}</button>
|
||||
<button className="is-primary" type="button" disabled={busy} onClick={() => void apply(draft)}>{t("common.save")}</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -398,15 +398,14 @@ function KnowledgeCard({ card, canManage, openKnowledge, reload }: {
|
||||
return (
|
||||
<section className="agent-card">
|
||||
<div className="agent-card-head is-row">
|
||||
<div><h3>Знания</h3><small>{knowledgeLine(card)}</small></div>
|
||||
<div><h3>{t("ai.knowledge")}</h3><small>{knowledgeLine(card)}</small></div>
|
||||
{canManage && (
|
||||
<button className="agent-inline-button" type="button" onClick={() => setPicking(true)}>
|
||||
<Icon name="plus" size={13} strokeWidth={2.2} />Выбрать
|
||||
</button>
|
||||
<Icon name="plus" size={13} strokeWidth={2.2} />{t("ai.select")}</button>
|
||||
)}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<p className="agent-knowledge-empty">Знания не прикреплены — агент отвечает только по инструкциям. Выберите статьи из библиотеки или портала поддержки.</p>
|
||||
<p className="agent-knowledge-empty">{t("ai.no_knowledge_attached_so_agent")}</p>
|
||||
) : (
|
||||
<div className="agent-knowledge-list">
|
||||
{rows.map((row) => (
|
||||
@@ -418,7 +417,7 @@ function KnowledgeCard({ card, canManage, openKnowledge, reload }: {
|
||||
{row.chip && <small className={`agent-chip is-${row.chipTone}`}>{row.chip}</small>}
|
||||
<small className="agent-knowledge-meta">{row.meta}</small>
|
||||
{canManage && (
|
||||
<button className="agent-knowledge-remove" type="button" aria-label="Убрать" title="Убрать" onClick={() => void detach(row.kind, row.id)}>
|
||||
<button className="agent-knowledge-remove" type="button" aria-label={t("common.remove")} title={t("common.remove")} onClick={() => void detach(row.kind, row.id)}>
|
||||
<Icon name="close" size={12} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
@@ -460,17 +459,17 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
|
||||
|
||||
return (
|
||||
<section className="agent-card is-side">
|
||||
<h3>Назначение</h3>
|
||||
<p>Группа определяет, кто из сотрудников видит диалоги агента; без группы — видны всем.</p>
|
||||
<h3>{t("ai.assignment")}</h3>
|
||||
<p>{t("ai.group_decides_which_operators_see")}</p>
|
||||
<div className="agent-side-fields">
|
||||
<label className="agent-field">
|
||||
<span>Название</span>
|
||||
<span>{t("common.title")}</span>
|
||||
{canManage
|
||||
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
|
||||
: <span className="agent-field-static">{card.name}</span>}
|
||||
</label>
|
||||
<label className="agent-field is-select">
|
||||
<span>Группа</span>
|
||||
<span>{t("common.group")}</span>
|
||||
<span className="agent-field-control">
|
||||
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
|
||||
{canManage ? (
|
||||
@@ -479,11 +478,11 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
|
||||
value={card.groupId === null ? "" : String(card.groupId)}
|
||||
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
|
||||
>
|
||||
<option value="">Без группы</option>
|
||||
<option value="">{t("common.no_group")}</option>
|
||||
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="agent-field-static">{card.groupName ?? "Без группы"}</span>
|
||||
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
|
||||
)}
|
||||
<Icon name="chevron" size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
@@ -515,11 +514,11 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
|
||||
return (
|
||||
<section className="agent-card is-side">
|
||||
<h3>Модель</h3>
|
||||
<p>Ключ провайдера — в «Настройки → AI-провайдер». Модель выбирается из каталога провайдера.</p>
|
||||
<h3>{t("common.model")}</h3>
|
||||
<p>{t("ai.provider_key_lives_under_settings")}</p>
|
||||
<div className="agent-side-fields">
|
||||
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
|
||||
<span>Провайдер</span>
|
||||
<span>{t("ai.provider")}</span>
|
||||
<span className="agent-field-control">
|
||||
{canManage ? (
|
||||
<select
|
||||
@@ -527,24 +526,24 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
|
||||
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
|
||||
>
|
||||
<option value="">Не выбран</option>
|
||||
<option value="">{t("ai.not_selected")}</option>
|
||||
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="agent-field-static">{providerName || "Не выбран"}</span>
|
||||
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
|
||||
)}
|
||||
<Icon name="chevron" size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
</label>
|
||||
<label className="agent-field is-model">
|
||||
<span>Модель</span>
|
||||
<span>{t("common.model")}</span>
|
||||
<span className="agent-field-control">
|
||||
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? "выберите провайдера" : card.model}</span>
|
||||
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
|
||||
<Icon name="search" size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
</label>
|
||||
<div className="agent-limit">
|
||||
<span>Лимит в день</span>
|
||||
<span>{t("ai.daily_limit")}</span>
|
||||
<span>
|
||||
{canManage
|
||||
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
|
||||
@@ -572,17 +571,17 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
|
||||
return (
|
||||
<section className="agent-card is-side">
|
||||
<div className="agent-card-head is-tight">
|
||||
<h3>Подключения</h3>
|
||||
<h3>{t("common.connections")}</h3>
|
||||
{canManage && (
|
||||
<button className="link has-icon" type="button" onClick={openIntegrations}>Интеграции<Icon name="external" size={13} strokeWidth={2.2} /></button>
|
||||
<button className="link has-icon" type="button" onClick={openIntegrations}>{t("common.integrations")}<Icon name="external" size={13} strokeWidth={2.2} /></button>
|
||||
)}
|
||||
</div>
|
||||
<p>Через них клиенты попадают к агенту. Одно подключение — один агент.</p>
|
||||
<p>{t("ai.customers_reach_agent_through_these")}</p>
|
||||
{card.connections.map((connection) => (
|
||||
<ConnectionRow
|
||||
connection={connection}
|
||||
status={connectionStatusMeta(connection.status)}
|
||||
action={canManage ? { label: "Отвязать", run: () => unbind(connection.id) } : null}
|
||||
action={canManage ? { label: t("ai.unbind"), run: () => unbind(connection.id) } : null}
|
||||
busy={busy}
|
||||
key={connection.id}
|
||||
/>
|
||||
@@ -590,9 +589,9 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
|
||||
{canManage && available.map((integration) => (
|
||||
<ConnectionRow
|
||||
connection={{ id: integration.id, provider: integration.provider, name: integration.name, status: integration.status, botUsername: "", email: "", allowedOrigins: [], widgetPublicKey: "" }}
|
||||
subtitle="Свободное подключение"
|
||||
status={{ text: "Свободно", bg: "var(--n-9)", color: "var(--n-4)" }}
|
||||
action={{ label: "Привязать", run: () => bind(integration.id) }}
|
||||
subtitle={t("ai.free_connection")}
|
||||
status={{ text: t("ai.free"), bg: "var(--n-9)", color: "var(--n-4)" }}
|
||||
action={{ label: t("ai.bind"), run: () => bind(integration.id) }}
|
||||
busy={busy}
|
||||
key={`free-${integration.id}`}
|
||||
/>
|
||||
@@ -600,11 +599,11 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
|
||||
{widget && (
|
||||
<div className="agent-widget">
|
||||
<div>
|
||||
<small>Код вставки виджета</small>
|
||||
<CopyButton className="agent-widget-copy" label="Копировать" value={webWidgetSnippet(widget.widgetPublicKey)} />
|
||||
<small>{t("ai.widget_embed_snippet")}</small>
|
||||
<CopyButton className="agent-widget-copy" label={t("common.copy")} value={webWidgetSnippet(widget.widgetPublicKey)} />
|
||||
</div>
|
||||
<code>{webWidgetSnippet(widget.widgetPublicKey)}</code>
|
||||
<small>Вставьте перед </body> на сайте</small>
|
||||
<small>{t("ai.paste_before_lt_body_gt")}</small>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { shortDate } from "../../shared/utils";
|
||||
import { fetchKnowledgeList } from "../ai/knowledge/api";
|
||||
import { listPortalArticles, listSupportPortals } from "../support-portals/api";
|
||||
import type { AgentCard } from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): один список из
|
||||
// материалов библиотеки и опубликованных статей порталов. Агент отвечает
|
||||
@@ -56,7 +57,7 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
|
||||
id: article.id,
|
||||
title: article.publishedRevision?.title ?? article.slug,
|
||||
icon: "globe" as const,
|
||||
meta: `Портал · ${portal.name}`,
|
||||
meta: t("ai.portal_named", { name: portal.name }),
|
||||
disabled: false,
|
||||
})));
|
||||
setChoices([
|
||||
@@ -66,7 +67,7 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
icon: "doc",
|
||||
meta: item.isEnabled ? `обновлено ${shortDate(item.updatedAt)}` : "Выключено",
|
||||
meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"),
|
||||
disabled: false,
|
||||
})),
|
||||
...articles,
|
||||
@@ -105,20 +106,20 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
|
||||
});
|
||||
onClose();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось сохранить выбор");
|
||||
setError(caught instanceof Error ? caught.message : t("ai.could_not_save_selection"));
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal className="agent-knowledge-modal" open width={560} title="Выбрать знания" onCancel={onClose} footer={null} destroyOnClose>
|
||||
<p className="agent-create-lead">Агент отвечает только по выбранным материалам: знаниям библиотеки и опубликованным статьям порталов.</p>
|
||||
<SearchInput className="agent-knowledge-search" placeholder="Поиск по названию" value={search} onChange={setSearch} />
|
||||
<Modal className="agent-knowledge-modal" open width={560} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<p className="agent-create-lead">{t("ai.agent_answers_only_from_selected")}</p>
|
||||
<SearchInput className="agent-knowledge-search" placeholder={t("ai.search_by_title")} value={search} onChange={setSearch} />
|
||||
{choices === null && !failed && <LoadingState />}
|
||||
{failed && <div className="agent-form-error">Не удалось загрузить список материалов</div>}
|
||||
{failed && <div className="agent-form-error">{t("ai.could_not_load_material_list")}</div>}
|
||||
{choices !== null && (
|
||||
<div className="agent-knowledge-choices">
|
||||
{visible.length === 0 && <p className="agent-knowledge-nothing">Ничего не найдено</p>}
|
||||
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
|
||||
{visible.map((choice) => (
|
||||
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
|
||||
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => toggle(choice.key)} />
|
||||
@@ -131,9 +132,9 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
|
||||
)}
|
||||
{error && <div className="agent-form-error">{error}</div>}
|
||||
<div className="agent-create-actions">
|
||||
<Button variant="secondary" onClick={onClose}>Отмена</Button>
|
||||
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={choices === null || saving} onClick={() => void submit()}>
|
||||
{saving ? "Сохранение…" : "Сохранить"}
|
||||
{saving ? t("ai.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
setAgentAiActive,
|
||||
type AgentCard,
|
||||
} from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Список агентов (дизайн-базлайн v2, «Агенты Baseline», кадры G1, G2, S1):
|
||||
// агент · группа · статус · модель · подключения · открытые · ⋯.
|
||||
@@ -38,28 +39,28 @@ function CreateAgentModal({ groups, onClose, onCreated }: { groups: EmployeeGrou
|
||||
const created = await createAgent({ name: name.trim(), groupId });
|
||||
onCreated(created.agent.id);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось создать агента");
|
||||
setError(caught instanceof Error ? caught.message : t("ai.could_not_create_agent"));
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal className="agent-create-modal" open width={440} title="Создать агента" onCancel={onClose} footer={null} destroyOnClose>
|
||||
<p className="agent-create-lead">Один шаг. Инструкции, модель, знания и подключения — потом, на карточке агента.</p>
|
||||
<Modal className="agent-create-modal" open width={440} title={t("profile.create_agent")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<p className="agent-create-lead">{t("ai.one_step_instructions_model_knowledge")}</p>
|
||||
<div className="agent-create-fields">
|
||||
<FormField label="Название" value={name} onChange={setName} placeholder="Например: Приёмная" />
|
||||
<FormField label={t("common.title")} value={name} onChange={setName} placeholder={t("ai.example_front_desk")} />
|
||||
<SelectField
|
||||
label="Группа"
|
||||
label={t("common.group")}
|
||||
value={groupId === null ? "" : String(groupId)}
|
||||
onChange={(value) => setGroupId(value ? Number(value) : null)}
|
||||
options={[["", "Без группы — диалоги видны всем"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
|
||||
options={[["", t("ai.without_group_conversations_visible_everyone")], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
|
||||
/>
|
||||
{error && <div className="agent-form-error">{error}</div>}
|
||||
</div>
|
||||
<div className="agent-create-actions">
|
||||
<Button variant="secondary" onClick={onClose}>Отмена</Button>
|
||||
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={!name.trim() || submitting} onClick={() => void submit()}>
|
||||
{submitting ? "Создание…" : "Создать"}
|
||||
{submitting ? t("ai.creating") : t("ai.create_2")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -73,14 +74,14 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
|
||||
const status = agentStatusMeta(card);
|
||||
const open = card.counters.openConversations;
|
||||
const menuItems = [
|
||||
{ key: "open", label: <button type="button" onClick={() => { setMenuOpen(false); openAgent(card.id); }}><Icon name="external" size={15} />Открыть карточку</button> },
|
||||
{ key: "open", label: <button type="button" onClick={() => { setMenuOpen(false); openAgent(card.id); }}><Icon name="external" size={15} />{t("admin.open_card")}</button> },
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "toggle",
|
||||
label: (
|
||||
<button type="button" className={card.aiStatus === "ACTIVE" ? "warning" : ""} onClick={() => { setMenuOpen(false); onToggleAi(card); }}>
|
||||
<Icon name={card.aiStatus === "ACTIVE" ? "pause" : "bolt"} size={15} />
|
||||
{card.aiStatus === "ACTIVE" ? "Остановить AI" : "Запустить AI"}
|
||||
{card.aiStatus === "ACTIVE" ? t("ai.stop_ai") : t("ai.start_ai")}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
@@ -99,7 +100,7 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
|
||||
</div>
|
||||
<span className={`agents-row-group ${card.groupId === null ? "is-none" : ""}`}>
|
||||
{card.groupId !== null && <i style={{ background: groupColorOf(card.groupId, card.groupColor) }} />}
|
||||
{card.groupName ?? "Без группы"}
|
||||
{card.groupName ?? t("common.no_group")}
|
||||
</span>
|
||||
<b className="agents-status" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
|
||||
<span className="agents-row-model">{agentModelLabel(card)}</span>
|
||||
@@ -118,7 +119,7 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
|
||||
</div>
|
||||
<div className="agents-row-open" style={{ color: open ? "var(--warning-text)" : "var(--n-5)" }}>{open || "—"}</div>
|
||||
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown">
|
||||
<button className="agents-row-menu" type="button" aria-label="Действия агента" title="Действия" onClick={(event) => event.stopPropagation()}><Icon name="more" size={16} strokeWidth={2} /></button>
|
||||
<button className="agents-row-menu" type="button" aria-label={t("ai.agent_actions")} title={t("common.actions")} onClick={(event) => event.stopPropagation()}><Icon name="more" size={16} strokeWidth={2} /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
@@ -138,7 +139,7 @@ export function AgentsPage({
|
||||
(page: number) => fetchAgentsPage({ group: "all", query: "" }, page),
|
||||
[],
|
||||
);
|
||||
const agents = usePagedResource(load, null, "Не удалось загрузить агентов");
|
||||
const agents = usePagedResource(load, null, t("ai.could_not_load_agents"));
|
||||
|
||||
async function toggleAi(card: AgentCard) {
|
||||
await setAgentAiActive(card.id, card.aiStatus !== "ACTIVE").catch(() => undefined);
|
||||
@@ -149,30 +150,30 @@ export function AgentsPage({
|
||||
<div className="agents-page">
|
||||
<header className="agents-header">
|
||||
<div>
|
||||
<h2>Агенты</h2>
|
||||
<p>Агент отвечает клиентам первым и собирает диалоги в группу</p>
|
||||
<h2>{t("common.agents")}</h2>
|
||||
<p>{t("ai.agent_answers_customers_first_collects")}</p>
|
||||
</div>
|
||||
<Button variant="primary" className="agents-create" icon="plus" iconSize={15} onClick={() => setCreating(true)}>Создать агента</Button>
|
||||
<Button variant="primary" className="agents-create" icon="plus" iconSize={15} onClick={() => setCreating(true)}>{t("profile.create_agent")}</Button>
|
||||
</header>
|
||||
{agents.errorText && <div className="agents-error">{agents.errorText}</div>}
|
||||
{agents.total === 0 ? (
|
||||
<div className="agents-empty">
|
||||
<span><Icon name="robot" size={24} strokeWidth={1.8} /></span>
|
||||
<div>
|
||||
<strong>Агент отвечает клиентам первым. Создайте первого</strong>
|
||||
<p>Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.</p>
|
||||
<strong>{t("ai.agent_answers_customers_first_create")}</strong>
|
||||
<p>{t("ai.name_group_all_takes_start")}</p>
|
||||
</div>
|
||||
<Button variant="primary" className="agents-create is-wide" onClick={() => setCreating(true)}>Создать агента</Button>
|
||||
<Button variant="primary" className="agents-create is-wide" onClick={() => setCreating(true)}>{t("profile.create_agent")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="agents-table">
|
||||
<div className="agents-thead">
|
||||
<span>Агент</span>
|
||||
<span>Группа</span>
|
||||
<span>Статус</span>
|
||||
<span>Модель</span>
|
||||
<span>Подключения</span>
|
||||
<span className="is-right">Открытые</span>
|
||||
<span>{t("common.agent")}</span>
|
||||
<span>{t("common.group")}</span>
|
||||
<span>{t("common.status")}</span>
|
||||
<span>{t("common.model")}</span>
|
||||
<span>{t("common.connections")}</span>
|
||||
<span className="is-right">{t("ai.open")}</span>
|
||||
<span />
|
||||
</div>
|
||||
{agents.items.map((card) => (
|
||||
|
||||
@@ -5,8 +5,9 @@ import { api } from "../../api/client";
|
||||
import type { PagedPayload } from "../../shared/usePagedResource";
|
||||
import { agentColorOf } from "../conversations/model";
|
||||
import { channelMap, providerKey } from "../../shared/providers";
|
||||
import { pluralRu, shortDate } from "../../shared/utils";
|
||||
import { shortDate } from "../../shared/utils";
|
||||
import type { AgentKnowledgeRef, AgentPortalArticleRef } from "../ai/model";
|
||||
import { t, tn } from "../../i18n";
|
||||
|
||||
export type AgentConnection = {
|
||||
id: number;
|
||||
@@ -131,9 +132,9 @@ export type AgentStatusTone = "active" | "paused" | "disabled";
|
||||
// таблица ST макета: «AI отвечает» на --ai, «Без AI» на предупреждении,
|
||||
// «Выключен» серым.
|
||||
const STATUS_META: Record<AgentStatusTone, { text: string; bg: string; color: string }> = {
|
||||
active: { text: "AI отвечает", bg: "color-mix(in srgb, var(--ai) 14%, var(--surface-card))", color: "var(--ai)" },
|
||||
paused: { text: "Без AI", bg: "var(--warning-bg)", color: "var(--warning-text)" },
|
||||
disabled: { text: "Выключен", bg: "var(--n-9)", color: "var(--n-4)" },
|
||||
active: { text: t("conversations.ai_replying"), bg: "color-mix(in srgb, var(--ai) 14%, var(--surface-card))", color: "var(--ai)" },
|
||||
paused: { text: t("ai.no_ai_2"), bg: "var(--warning-bg)", color: "var(--warning-text)" },
|
||||
disabled: { text: t("ai.off"), bg: "var(--n-9)", color: "var(--n-4)" },
|
||||
};
|
||||
|
||||
export function agentStatusTone(card: AgentCard): AgentStatusTone {
|
||||
@@ -168,31 +169,31 @@ export function agentTint(provider: string): { color: string; bg: string; full:
|
||||
export function connectionSubtitle(connection: AgentConnection): string {
|
||||
const key = providerKey(connection.provider);
|
||||
if (key === "TG") return connection.botUsername ? `Telegram · @${connection.botUsername}` : "Telegram";
|
||||
if (key === "WEB") return connection.allowedOrigins[0] ?? "Web-виджет";
|
||||
if (key === "WEB") return connection.allowedOrigins[0] ?? t("common.web_widget");
|
||||
if (key === "EMAIL") return connection.email || "Email";
|
||||
return key ? channelMap[key].full : connection.provider;
|
||||
}
|
||||
|
||||
// Таблица CS макета: подключено · ошибка · не проверено.
|
||||
const CONNECTION_STATUS_META: Record<string, { text: string; bg: string; color: string }> = {
|
||||
OK: { text: "Подключено", bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||
ERROR: { text: "Ошибка", bg: "var(--error-bg)", color: "var(--error-text)" },
|
||||
OK: { text: t("common.connected"), bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||
ERROR: { text: t("common.error"), bg: "var(--error-bg)", color: "var(--error-text)" },
|
||||
};
|
||||
|
||||
export function connectionStatusMeta(status: string): { text: string; bg: string; color: string } {
|
||||
return CONNECTION_STATUS_META[status] ?? { text: "Не проверено", bg: "var(--n-9)", color: "var(--n-4)" };
|
||||
return CONNECTION_STATUS_META[status] ?? { text: t("ai.not_checked"), bg: "var(--n-9)", color: "var(--n-4)" };
|
||||
}
|
||||
|
||||
/** «5 открытых диалогов» / «нет диалогов» — подзаголовок шапки карточки. */
|
||||
export function openDialogsLine(count: number): string {
|
||||
return count === 0 ? "нет диалогов" : pluralRu(count, ["открытый диалог", "открытых диалога", "открытых диалогов"]);
|
||||
return count === 0 ? t("ai.no_conversations") : tn("plural.open_conversations", count);
|
||||
}
|
||||
|
||||
/** «создан 12 мар 2026»; созданный сегодня — «сегодня» (кадр G5). */
|
||||
export function createdLabel(value: string, now = new Date()): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
if (date.toDateString() === now.toDateString()) return "сегодня";
|
||||
if (date.toDateString() === now.toDateString()) return t("ai.today");
|
||||
return `${shortDate(date)} ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
@@ -217,9 +218,9 @@ export function knowledgeRows(card: AgentCard): AgentKnowledgeRow[] {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
icon: "doc",
|
||||
chip: item.isEnabled ? "" : "Выключено",
|
||||
chip: item.isEnabled ? "" : t("common.off"),
|
||||
chipTone: "muted",
|
||||
meta: item.updatedAt ? `обновлено ${shortDate(item.updatedAt)}` : "",
|
||||
meta: item.updatedAt ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : "",
|
||||
href: "",
|
||||
})),
|
||||
...card.portalArticles.map((article): AgentKnowledgeRow => ({
|
||||
@@ -228,9 +229,9 @@ export function knowledgeRows(card: AgentCard): AgentKnowledgeRow[] {
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
icon: "globe",
|
||||
chip: `Портал · ${article.portal.name}`,
|
||||
chip: t("ai.portal_named", { name: article.portal.name }),
|
||||
chipTone: "accent",
|
||||
meta: "статья",
|
||||
meta: t("ai.article_meta"),
|
||||
href: article.publicUrl,
|
||||
})),
|
||||
];
|
||||
@@ -241,12 +242,12 @@ export function knowledgeLine(card: AgentCard): string {
|
||||
const knowledge = card.knowledge.length;
|
||||
const articles = card.portalArticles.length;
|
||||
const selected = knowledge + articles;
|
||||
if (selected === 0) return "ничего не выбрано";
|
||||
const head = `${selected} из ${card.knowledgeTotal}`;
|
||||
if (selected === 0) return t("ai.nothing_selected");
|
||||
const head = t("common.n_of_m", { selected, total: card.knowledgeTotal });
|
||||
if (knowledge === 0 || articles === 0) return head;
|
||||
const parts = [
|
||||
pluralRu(knowledge, ["знание", "знания", "знаний"]),
|
||||
pluralRu(articles, ["статья", "статьи", "статей"]),
|
||||
tn("plural.knowledge", knowledge),
|
||||
tn("plural.articles", articles),
|
||||
];
|
||||
return `${head} · ${parts.join(" + ")}`;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useState } from "react";
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { Segmented } from "../../../shared/ui";
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import { pluralRu } from "../../../shared/utils";
|
||||
import type { AgentRef } from "../../agents/model";
|
||||
import type { KnowledgeItem } from "./types";
|
||||
import { t, tn } from "../../../i18n";
|
||||
|
||||
// Прикрепление знаний к агенту (дизайн-базлайн v2, кадр KB3). Один агент за
|
||||
// операцию: SPEC-CHATBALLS-0029 §6 требует атомарности и журналирования, поэтому
|
||||
@@ -14,9 +14,8 @@ import type { KnowledgeItem } from "./types";
|
||||
|
||||
type Mode = "attach" | "detach";
|
||||
|
||||
const MODES: Array<[Mode, string]> = [["attach", "Прикрепить"], ["detach", "Открепить"]];
|
||||
const MODES: Array<[Mode, string]> = [["attach", t("common.attach")], ["detach", t("common.detach")]];
|
||||
|
||||
const KNOWLEDGE_FORMS: [string, string, string] = ["знание", "знания", "знаний"];
|
||||
|
||||
type Skip = { item: KnowledgeItem; reason: string };
|
||||
|
||||
@@ -29,18 +28,18 @@ function skipsFor(items: KnowledgeItem[], agent: AgentRef | null, mode: Mode): S
|
||||
items.forEach((item) => {
|
||||
const attached = (item.agentIds ?? []).includes(agentId);
|
||||
if (mode === "attach") {
|
||||
if (attached) skips.push({ item, reason: "уже прикреплён к этому агенту" });
|
||||
else if (!item.isEnabled) skips.push({ item, reason: "выключен в ответах" });
|
||||
if (attached) skips.push({ item, reason: t("ai.already_attached_agent") });
|
||||
else if (!item.isEnabled) skips.push({ item, reason: t("ai.switched_off_replies") });
|
||||
} else if (!attached) {
|
||||
skips.push({ item, reason: "не прикреплён к этому агенту" });
|
||||
skips.push({ item, reason: t("ai.not_attached_agent") });
|
||||
}
|
||||
});
|
||||
return skips;
|
||||
}
|
||||
|
||||
function agentMeta(agent: AgentRef): string {
|
||||
if (agent.aiStatus !== "ACTIVE") return "Без AI — знания не используются";
|
||||
return `AI отвечает · ${pluralRu(agent.knowledgeCount, KNOWLEDGE_FORMS)} прикреплено`;
|
||||
if (agent.aiStatus !== "ACTIVE") return t("ai.no_ai_knowledge_not_used");
|
||||
return t("ai.ai_replies_attached", { count: tn("plural.knowledge", agent.knowledgeCount) });
|
||||
}
|
||||
|
||||
export function KnowledgeAgentDialog({
|
||||
@@ -67,10 +66,10 @@ export function KnowledgeAgentDialog({
|
||||
<header className="knowledge-agent-dialog-head">
|
||||
<div className="knowledge-agent-dialog-title">
|
||||
<div>
|
||||
<h3>{mode === "attach" ? "Прикрепить к агенту" : "Открепить от агента"}</h3>
|
||||
<p>Выбрано {pluralRu(items.length, KNOWLEDGE_FORMS)}. Один агент за операцию — так видно, что именно изменилось.</p>
|
||||
<h3>{mode === "attach" ? t("ai.attach_agent") : t("ai.detach_from_agent")}</h3>
|
||||
<p>{t("ai.selected_one_agent_note", { count: tn("plural.knowledge", items.length) })}</p>
|
||||
</div>
|
||||
<button aria-label="Закрыть" className="knowledge-dialog-close" title="Закрыть" type="button" onClick={onCancel}>
|
||||
<button aria-label={t("common.close")} className="knowledge-dialog-close" title={t("common.close")} type="button" onClick={onCancel}>
|
||||
<Icon name="close" size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -78,7 +77,7 @@ export function KnowledgeAgentDialog({
|
||||
</header>
|
||||
|
||||
<div className="knowledge-agent-dialog-body">
|
||||
<span className="knowledge-agent-dialog-label">АГЕНТ</span>
|
||||
<span className="knowledge-agent-dialog-label">{t("ai.agent")}</span>
|
||||
<div className="knowledge-agent-picks">
|
||||
{agents.map((item) => {
|
||||
const picked = item.aiAgentId === agentId;
|
||||
@@ -95,11 +94,11 @@ export function KnowledgeAgentDialog({
|
||||
<strong>{item.name}</strong>
|
||||
<small>{agentMeta(item)}</small>
|
||||
</span>
|
||||
<small className="knowledge-agent-tag">{picked ? "выбран" : noAi ? "без AI" : "доступен"}</small>
|
||||
<small className="knowledge-agent-tag">{picked ? t("ai.selected") : noAi ? t("ai.no_ai") : t("ai.available")}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{agents.length === 0 && <p className="knowledge-agent-empty">Агентов пока нет — создайте агента, чтобы прикреплять к нему знания.</p>}
|
||||
{agents.length === 0 && <p className="knowledge-agent-empty">{t("ai.there_no_agents_yet_create")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,24 +106,22 @@ export function KnowledgeAgentDialog({
|
||||
<div className="knowledge-agent-warning">
|
||||
<Icon name="alert" size={16} strokeWidth={2} />
|
||||
<span>
|
||||
<b>{skips.length} из {items.length} будут пропущены:</b>{" "}
|
||||
{skips.map((skip) => `«${skip.item.title}» ${skip.reason}`).join(", ")}. Состав знаний
|
||||
агента по ним не изменится.
|
||||
<b>{t("ai.skips_summary", { skipped: skips.length, total: items.length })}</b>{" "}
|
||||
{skips.map((skip) => `«${skip.item.title}» ${skip.reason}`).join(", ")}. {t("ai.skips_tail")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="knowledge-agent-dialog-foot">
|
||||
<span>
|
||||
Операция атомарна: при недоступном идентификаторе не меняется ничего. Требуется право <code>ai.manage</code>.
|
||||
<span>{t("ai.operation_atomic_if_any_identifier")}<code>ai.manage</code>.
|
||||
</span>
|
||||
<Button variant="secondary" disabled={busy} onClick={onCancel}>Отмена</Button>
|
||||
<Button variant="secondary" disabled={busy} onClick={onCancel}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || agentId === null || applied === 0}
|
||||
onClick={() => agentId !== null && onSubmit(agentId, mode)}
|
||||
>
|
||||
{mode === "attach" ? "Прикрепить" : "Открепить"} {applied}
|
||||
{mode === "attach" ? t("common.attach") : t("common.detach")} {applied}
|
||||
</Button>
|
||||
</footer>
|
||||
</Modal>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { pluralRu } from "../../../shared/utils";
|
||||
import { t, tn } from "../../../i18n";
|
||||
|
||||
// Панель массовых операций (дизайн-базлайн v2, кадр KB2): тёмная плашка по
|
||||
// центру снизу, пока живо выделение. Прикрепление к агенту — главное действие.
|
||||
@@ -26,26 +26,21 @@ export function KnowledgeBulkBar({
|
||||
return (
|
||||
<div className="knowledge-bulk-bar">
|
||||
<span className="knowledge-bulk-count">
|
||||
<strong>Выбрано {count}</strong>
|
||||
<small>{pluralRu(count, ["знание", "знания", "знаний"]).replace(`${count} `, "")}</small>
|
||||
<strong>{t("common.selected_count", { count })}</strong>
|
||||
<small>{tn("plural.knowledge", count).replace(`${count} `, "")}</small>
|
||||
</span>
|
||||
<button className="is-primary" disabled={busy} type="button" onClick={onAttach}>
|
||||
<Icon name="robot" size={14} strokeWidth={1.9} />Прикрепить к агенту
|
||||
</button>
|
||||
<Icon name="robot" size={14} strokeWidth={1.9} />{t("ai.attach_agent")}</button>
|
||||
<button disabled={busy} type="button" onClick={onMove}>
|
||||
<Icon name="move" size={14} strokeWidth={1.9} />Переместить
|
||||
</button>
|
||||
<Icon name="move" size={14} strokeWidth={1.9} />{t("ai.move")}</button>
|
||||
<button disabled={busy} type="button" onClick={onEnable}>
|
||||
<Icon name="eye" size={14} strokeWidth={1.9} />Включить
|
||||
</button>
|
||||
<Icon name="eye" size={14} strokeWidth={1.9} />{t("ai.turn")}</button>
|
||||
<button disabled={busy} type="button" onClick={onDisable}>
|
||||
<Icon name="eyeOff" size={14} strokeWidth={1.9} />Выключить
|
||||
</button>
|
||||
<Icon name="eyeOff" size={14} strokeWidth={1.9} />{t("ai.turn_off")}</button>
|
||||
<button className="is-danger" disabled={busy} type="button" onClick={onRemove}>
|
||||
<Icon name="trash" size={14} strokeWidth={1.9} />Удалить
|
||||
</button>
|
||||
<Icon name="trash" size={14} strokeWidth={1.9} />{t("common.delete")}</button>
|
||||
<i className="knowledge-bulk-divider" />
|
||||
<button aria-label="Снять выделение" className="knowledge-bulk-close" title="Снять выделение" type="button" onClick={onClear}>
|
||||
<button aria-label={t("ai.clear_selection")} className="knowledge-bulk-close" title={t("ai.clear_selection")} type="button" onClick={onClear}>
|
||||
<Icon name="close" size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DecisionDialog } from "../../../shared/DecisionDialog";
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { EmptyState, LoadingState } from "../../../shared/ui";
|
||||
import { Button, CopyButton } from "../../../shared/ui-controls";
|
||||
import { pluralRu, shortDateTime } from "../../../shared/utils";
|
||||
import { shortDateTime } from "../../../shared/utils";
|
||||
import type { RouteKey } from "../../../types";
|
||||
import type { AgentRef } from "../../agents/model";
|
||||
import { MarkdownContent } from "../../help-center/MarkdownContent";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type KnowledgeItem,
|
||||
} from "./model";
|
||||
import { useKnowledgeCategories } from "./useKnowledgeCategories";
|
||||
import { fmt, t, tn } from "../../../i18n";
|
||||
|
||||
// Карточка знания (дизайн-базлайн v2, кадр KB4): шапка как у портала —
|
||||
// состояние в ответах, фрагменты и агенты чипами; тело — содержимое до 720px
|
||||
@@ -87,9 +88,9 @@ export function KnowledgeCardPage({
|
||||
}
|
||||
}
|
||||
|
||||
if (!knowledgeId) return <div className="knowledge-card"><EmptyState title="Знание не выбрано" /></div>;
|
||||
if (!knowledgeId) return <div className="knowledge-card"><EmptyState title={t("ai.no_knowledge_item_selected")} /></div>;
|
||||
if (loading || catalog.loading) return <div className="knowledge-card"><LoadingState /></div>;
|
||||
if (failed || catalog.error || !item) return <div className="knowledge-card"><EmptyState title="Не удалось загрузить знание" /></div>;
|
||||
if (failed || catalog.error || !item) return <div className="knowledge-card"><EmptyState title={t("ai.could_not_load_knowledge_item")} /></div>;
|
||||
|
||||
const path = knowledgeCategoryPath(catalog.categories, item.category.id);
|
||||
const crumbs = path ? path.split(" / ") : [item.category.name];
|
||||
@@ -98,18 +99,18 @@ export function KnowledgeCardPage({
|
||||
const characters = (item.content ?? "").length;
|
||||
|
||||
const menuItems = canManage ? [
|
||||
{ key: "move", label: <button type="button" onClick={() => setMovingOpen(true)}><Icon name="move" size={15} strokeWidth={1.9} />Переместить в категорию</button> },
|
||||
{ key: "answers", label: <button type="button" onClick={() => void run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }), "Не удалось изменить участие в ответах")}><Icon name={item.isEnabled ? "eyeOff" : "eye"} size={15} strokeWidth={1.9} />{item.isEnabled ? "Выключить в ответах" : "Включить в ответах"}</button> },
|
||||
{ key: "reindex", label: <button type="button" onClick={() => void run(() => reindexKnowledge(item.id), "Не удалось переиндексировать знание")}><Icon name="undo" size={15} strokeWidth={1.9} />Переиндексировать</button> },
|
||||
{ key: "move", label: <button type="button" onClick={() => setMovingOpen(true)}><Icon name="move" size={15} strokeWidth={1.9} />{t("ai.move_category")}</button> },
|
||||
{ key: "answers", label: <button type="button" onClick={() => void run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }), t("ai.could_not_change_participation_replies"))}><Icon name={item.isEnabled ? "eyeOff" : "eye"} size={15} strokeWidth={1.9} />{item.isEnabled ? t("ai.switch_off_replies") : t("ai.switch_replies")}</button> },
|
||||
{ key: "reindex", label: <button type="button" onClick={() => void run(() => reindexKnowledge(item.id), t("ai.could_not_reindex_knowledge_item"))}><Icon name="undo" size={15} strokeWidth={1.9} />{t("ai.reindex")}</button> },
|
||||
{ key: "divider", type: "divider" as const },
|
||||
{ key: "delete", label: <button className="danger" type="button" onClick={() => setDeleteOpen(true)}><Icon name="trash" size={15} strokeWidth={1.9} />Удалить знание</button> },
|
||||
{ key: "delete", label: <button className="danger" type="button" onClick={() => setDeleteOpen(true)}><Icon name="trash" size={15} strokeWidth={1.9} />{t("ai.delete_knowledge_item")}</button> },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<section className="knowledge-card">
|
||||
<div className="knowledge-card-head">
|
||||
<nav className="knowledge-breadcrumbs">
|
||||
<button className="link is-strong" type="button" onClick={() => setRoute("knowledge")}>База знаний</button>
|
||||
<button className="link is-strong" type="button" onClick={() => setRoute("knowledge")}>{t("common.knowledge_base")}</button>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={`${crumb}-${index}`}>
|
||||
<span className="knowledge-crumb-sep">/</span>
|
||||
@@ -122,35 +123,31 @@ export function KnowledgeCardPage({
|
||||
<div className="knowledge-card-title">
|
||||
<h2>{item.title}</h2>
|
||||
<span className={`knowledge-answer-pill${item.isEnabled ? " is-on" : ""}`}>
|
||||
<i />{item.isEnabled ? "В ответах агента" : "Не участвует в ответах"}
|
||||
<i />{item.isEnabled ? t("ai.agent_s_replies") : t("ai.not_used_replies")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="knowledge-card-chips">
|
||||
<span className="knowledge-chip">
|
||||
<Icon name="box" size={14} strokeWidth={1.9} />
|
||||
{pluralRu(fragments, ["фрагмент", "фрагмента", "фрагментов"])} · пересобраны {shortDateTime(item.updatedAt)}
|
||||
{t("ai.chunks_rebuilt_at", { chunks: tn("plural.chunks", fragments), date: shortDateTime(item.updatedAt) })}
|
||||
</span>
|
||||
<span className="knowledge-chip">
|
||||
<Icon name="robot" size={14} strokeWidth={1.9} />
|
||||
{pluralRu(attached.length, ["агент", "агента", "агентов"])}
|
||||
{tn("plural.agents", attached.length)}
|
||||
</span>
|
||||
{item.updatedBy && <span className="knowledge-card-author">обновил {item.updatedBy} · {shortDateTime(item.updatedAt)}</span>}
|
||||
{item.updatedBy && <span className="knowledge-card-author">{t("ai.updated_by_at", { name: item.updatedBy, date: shortDateTime(item.updatedAt) })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-card-actions">
|
||||
{canManage && (
|
||||
<Button variant="secondary" className="knowledge-secondary-action" icon="robot" onClick={() => setAttachOpen(true)}>
|
||||
Прикрепить к агенту
|
||||
</Button>
|
||||
<Button variant="secondary" className="knowledge-secondary-action" icon="robot" onClick={() => setAttachOpen(true)}>{t("ai.attach_agent")}</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button variant="primary" className="knowledge-primary-action" icon="edit" onClick={() => openKnowledgeEditor(item.id)}>
|
||||
Редактировать
|
||||
</Button>
|
||||
<Button variant="primary" className="knowledge-primary-action" icon="edit" onClick={() => openKnowledgeEditor(item.id)}>{t("common.edit_item")}</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Dropdown menu={{ items: menuItems }} overlayClassName="app-dropdown is-knowledge-menu" placement="bottomRight" trigger={["click"]}>
|
||||
<button aria-label="Ещё" className="knowledge-more-button" title="Ещё" type="button">
|
||||
<button aria-label={t("common.more")} className="knowledge-more-button" title={t("common.more")} type="button">
|
||||
<Icon name="more" size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</Dropdown>
|
||||
@@ -172,23 +169,23 @@ export function KnowledgeCardPage({
|
||||
<aside className="knowledge-rail">
|
||||
<div className="knowledge-rail-block is-top">
|
||||
<div>
|
||||
<span className="knowledge-rail-label">КАТЕГОРИЯ</span>
|
||||
<span className="knowledge-rail-label">{t("common.category")}</span>
|
||||
<span className="knowledge-rail-category">
|
||||
<Icon name="folder" size={13} strokeWidth={1.8} />{path || item.category.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="knowledge-rail-toggle">
|
||||
<span>
|
||||
<strong>Участвует в ответах</strong>
|
||||
<small>выключенное знание не попадает в retrieval</small>
|
||||
<strong>{t("ai.used_replies")}</strong>
|
||||
<small>{t("ai.switched_off_item_never_reaches")}</small>
|
||||
</span>
|
||||
<button
|
||||
aria-label="Участвует в ответах"
|
||||
aria-label={t("ai.used_replies")}
|
||||
aria-pressed={item.isEnabled}
|
||||
className={`knowledge-switch${item.isEnabled ? " is-on" : ""}`}
|
||||
disabled={!canManage || busy}
|
||||
type="button"
|
||||
onClick={() => void run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }), "Не удалось изменить участие в ответах")}
|
||||
onClick={() => void run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }), t("ai.could_not_change_participation_replies"))}
|
||||
>
|
||||
<i />
|
||||
</button>
|
||||
@@ -197,8 +194,8 @@ export function KnowledgeCardPage({
|
||||
|
||||
<div className="knowledge-rail-block">
|
||||
<div className="knowledge-rail-head">
|
||||
<span>ВЛОЖЕНИЯ</span>
|
||||
<small>ссылки уходят в ответ агента</small>
|
||||
<span>{t("ai.attachments")}</span>
|
||||
<small>{t("ai.links_go_out_agent_s")}</small>
|
||||
</div>
|
||||
<div className="knowledge-rail-files">
|
||||
{item.attachments.map((attachment) => (
|
||||
@@ -211,14 +208,14 @@ export function KnowledgeCardPage({
|
||||
<CopyButton className="knowledge-rail-file-copy" label="" value={attachment.url} />
|
||||
</div>
|
||||
))}
|
||||
{item.attachments.length === 0 && <p className="knowledge-rail-empty">Вложений нет.</p>}
|
||||
{item.attachments.length === 0 && <p className="knowledge-rail-empty">{t("ai.no_attachments")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="knowledge-rail-block">
|
||||
<div className="knowledge-rail-head">
|
||||
<span>АГЕНТЫ</span>
|
||||
{canManage && <button type="button" onClick={() => setAttachOpen(true)}>Изменить</button>}
|
||||
<span>{t("common.agents_2")}</span>
|
||||
{canManage && <button type="button" onClick={() => setAttachOpen(true)}>{t("common.edit")}</button>}
|
||||
</div>
|
||||
<div className="knowledge-rail-agents">
|
||||
{attached.map(({ agent, meta, answering }) => (
|
||||
@@ -228,30 +225,29 @@ export function KnowledgeCardPage({
|
||||
<strong>{agent.name}</strong>
|
||||
<small>{meta}</small>
|
||||
</span>
|
||||
<small className={`knowledge-agent-state${answering ? " is-on" : ""}`}>{answering ? "активен" : "не в ответах"}</small>
|
||||
<small className={`knowledge-agent-state${answering ? " is-on" : ""}`}>{answering ? t("ai.active") : t("ai.not_replies")}</small>
|
||||
</div>
|
||||
))}
|
||||
{attached.length === 0 && <p className="knowledge-rail-empty">Знание не прикреплено ни к одному агенту — в ответах оно не используется.</p>}
|
||||
{attached.length === 0 && <p className="knowledge-rail-empty">{t("ai.item_attached_no_agent_so")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="knowledge-rail-block">
|
||||
<span className="knowledge-rail-label">ИНДЕКСАЦИЯ</span>
|
||||
<span className="knowledge-rail-label">{t("ai.indexing")}</span>
|
||||
<div className="knowledge-index-box">
|
||||
<div className="knowledge-index-count">
|
||||
<strong>{fragments}</strong>
|
||||
<span>фрагментов · {characters.toLocaleString("ru-RU")} знаков</span>
|
||||
<span>{t("ai.chunks_and_characters", { characters: fmt.number(characters) })}</span>
|
||||
</div>
|
||||
<p>Фрагменты пересобираются при сохранении. Пока идёт пересборка, агент отвечает по прежней версии.</p>
|
||||
<p>{t("ai.chunks_rebuilt_save_while_rebuild")}</p>
|
||||
{canManage && (
|
||||
<button
|
||||
className="knowledge-reindex-button"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void run(() => reindexKnowledge(item.id), "Не удалось переиндексировать знание")}
|
||||
onClick={() => void run(() => reindexKnowledge(item.id), t("ai.could_not_reindex_knowledge_item"))}
|
||||
>
|
||||
<Icon name="undo" size={13} strokeWidth={1.9} />Переиндексировать
|
||||
</button>
|
||||
<Icon name="undo" size={13} strokeWidth={1.9} />{t("ai.reindex")}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,7 +264,7 @@ export function KnowledgeCardPage({
|
||||
setAttachOpen(false);
|
||||
void run(
|
||||
() => linkKnowledgeToAgent({ agentId, action: mode, knowledgeIds: [item.id] }),
|
||||
"Не удалось изменить знания агента",
|
||||
t("ai.could_not_change_agent_s"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -287,7 +283,7 @@ export function KnowledgeCardPage({
|
||||
await bulkMoveKnowledge({ knowledgeIds: [item.id], categoryId });
|
||||
await catalog.reload();
|
||||
},
|
||||
"Не удалось переместить знание",
|
||||
t("ai.could_not_move_knowledge_item"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -298,10 +294,10 @@ export function KnowledgeCardPage({
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
tone="danger"
|
||||
icon="trash"
|
||||
title="Удалить знание?"
|
||||
description={`«${item.title}» и его вложения будут удалены. У агентов, которым знание прикреплено, оно исчезнет из ответов.`}
|
||||
title={t("ai.delete_knowledge_item_2")}
|
||||
description={t("ai.item_and_files_deleted", { title: item.title })}
|
||||
actions={<>
|
||||
<Button variant="secondary" onClick={() => setDeleteOpen(false)}>Отмена</Button>
|
||||
<Button variant="secondary" onClick={() => setDeleteOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
disabled={busy}
|
||||
@@ -310,11 +306,9 @@ export function KnowledgeCardPage({
|
||||
setBusy(true);
|
||||
void deleteKnowledgeItem(item.id)
|
||||
.then(() => setRoute("knowledge"))
|
||||
.catch(() => { setError("Не удалось удалить знание"); setBusy(false); });
|
||||
.catch(() => { setError(t("ai.could_not_delete_knowledge_item")); setBusy(false); });
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
>{t("common.delete")}</Button>
|
||||
</>}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DecisionDialog } from "../../../shared/DecisionDialog";
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { LoadingState } from "../../../shared/ui";
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import { pluralRu } from "../../../shared/utils";
|
||||
import {
|
||||
buildCategoryTree,
|
||||
planCategoryDrop,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
updateKnowledgeCategory,
|
||||
} from "./model";
|
||||
import { useKnowledgeCategories } from "./useKnowledgeCategories";
|
||||
import { t, tn } from "../../../i18n";
|
||||
|
||||
// Категории знаний (дизайн-базлайн v2, кадр KB7): отдельный экран, а не
|
||||
// модалка — дерево строками с перетаскиванием, инлайн-переименованием и
|
||||
@@ -31,17 +31,17 @@ function flatten(nodes: ManagedContentCategoryNode[], depth = 0): Row[] {
|
||||
|
||||
/** «18 знаний · 2 вложенные» — подпись строки категории. */
|
||||
function countLabel(node: ManagedContentCategoryNode): string {
|
||||
if (node.count === 0 && node.children.length === 0) return "пусто";
|
||||
const knowledge = pluralRu(node.count, ["знание", "знания", "знаний"]);
|
||||
if (node.count === 0 && node.children.length === 0) return t("ai.empty");
|
||||
const knowledge = tn("plural.knowledge", node.count);
|
||||
if (node.children.length === 0) return knowledge;
|
||||
return `${knowledge} · ${pluralRu(node.children.length, ["вложенная", "вложенные", "вложенных"])}`;
|
||||
return `${knowledge} · ${tn("plural.nested", node.children.length)}`;
|
||||
}
|
||||
|
||||
/** Почему нельзя удалить: системная, есть вложенные, есть материалы. */
|
||||
function deleteBlock(node: ManagedContentCategoryNode): string | null {
|
||||
if (node.isSystem) return "системная категория";
|
||||
if (node.children.length > 0) return "есть вложенные категории";
|
||||
if (node.count > 0) return "категория не пуста";
|
||||
if (node.isSystem) return t("ai.system_category");
|
||||
if (node.children.length > 0) return t("ai.has_nested_categories");
|
||||
if (node.count > 0) return t("ai.category_not_empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function KnowledgeCategoriesPage({
|
||||
cancel();
|
||||
await catalog.reload();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось изменить категории");
|
||||
setError(caught instanceof Error ? caught.message : t("shared.could_not_change_categories"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -124,11 +124,11 @@ export function KnowledgeCategoriesPage({
|
||||
<section className="knowledge-card">
|
||||
<div className="knowledge-card-head is-plain">
|
||||
<nav className="knowledge-breadcrumbs">
|
||||
<button className="link is-strong" type="button" onClick={() => setRoute("knowledge")}>База знаний</button>
|
||||
<span><span className="knowledge-crumb-sep">/</span><b>Категории</b></span>
|
||||
<button className="link is-strong" type="button" onClick={() => setRoute("knowledge")}>{t("common.knowledge_base")}</button>
|
||||
<span><span className="knowledge-crumb-sep">/</span><b>{t("common.categories")}</b></span>
|
||||
</nav>
|
||||
<h2>Категории</h2>
|
||||
<p>Дерево до трёх уровней. Перетащите строку, чтобы сменить родителя или порядок.</p>
|
||||
<h2>{t("common.categories")}</h2>
|
||||
<p>{t("ai.tree_up_three_levels_deep")}</p>
|
||||
</div>
|
||||
|
||||
<div className="knowledge-plain-body">
|
||||
@@ -151,7 +151,7 @@ export function KnowledgeCategoriesPage({
|
||||
drop(node.id, dropPosition(event, Boolean(node.isSystem)));
|
||||
}}
|
||||
>
|
||||
<span className="knowledge-category-grip" title="Перетащить"><Icon name="grip" size={15} strokeWidth={2} /></span>
|
||||
<span className="knowledge-category-grip" title={t("ai.drag")}><Icon name="grip" size={15} strokeWidth={2} /></span>
|
||||
{editing ? (
|
||||
<>
|
||||
<input
|
||||
@@ -176,23 +176,21 @@ export function KnowledgeCategoriesPage({
|
||||
if (!name || name === node.name) cancel();
|
||||
else void run(() => updateKnowledgeCategory(node.id, { name }));
|
||||
}}
|
||||
>
|
||||
Готово
|
||||
</button>
|
||||
<button className="knowledge-category-cancel" type="button" onClick={cancel}>Отмена</button>
|
||||
>{t("common.done")}</button>
|
||||
<button className="knowledge-category-cancel" type="button" onClick={cancel}>{t("common.cancel")}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="knowledge-category-name">
|
||||
<strong style={{ fontWeight: depth === 0 ? 600 : 500 }}>{node.name}</strong>
|
||||
{node.isSystem && <small className="knowledge-category-tag">системная</small>}
|
||||
{node.isSystem && <small className="knowledge-category-tag">{t("ai.system")}</small>}
|
||||
</span>
|
||||
<small className="knowledge-category-count">{countLabel(node)}</small>
|
||||
{canManage && (
|
||||
<span className="knowledge-category-actions">
|
||||
<button
|
||||
disabled={depth >= 2}
|
||||
title={depth >= 2 ? "Глубже трёх уровней категории не вкладываются" : "Добавить вложенную"}
|
||||
title={depth >= 2 ? t("ai.categories_do_not_nest_deeper") : t("ai.add_nested_one")}
|
||||
type="button"
|
||||
onClick={() => { setEditingId(null); setCreateParentId(node.id); setDraftName(""); }}
|
||||
>
|
||||
@@ -200,7 +198,7 @@ export function KnowledgeCategoriesPage({
|
||||
</button>
|
||||
<button
|
||||
disabled={Boolean(node.isSystem)}
|
||||
title={node.isSystem ? "Системную категорию нельзя переименовать" : "Переименовать"}
|
||||
title={node.isSystem ? t("ai.system_category_cannot_renamed") : t("ai.rename")}
|
||||
type="button"
|
||||
onClick={() => { setCreateParentId(undefined); setEditingId(node.id); setDraftName(node.name); }}
|
||||
>
|
||||
@@ -208,7 +206,7 @@ export function KnowledgeCategoriesPage({
|
||||
</button>
|
||||
<button
|
||||
className={`knowledge-category-delete${block ? " is-blocked" : ""}`}
|
||||
title={block ? `Удалить нельзя: ${block}` : "Удалить категорию"}
|
||||
title={block ? t("ai.cannot_delete_because", { reason: block }) : t("ai.delete_category")}
|
||||
type="button"
|
||||
onClick={() => (block
|
||||
? setBlocked({ name: node.name, reason: block })
|
||||
@@ -226,7 +224,7 @@ export function KnowledgeCategoriesPage({
|
||||
<span className="knowledge-category-grip" />
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="Название категории"
|
||||
placeholder={t("shared.category_name")}
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -234,8 +232,8 @@ export function KnowledgeCategoriesPage({
|
||||
if (event.key === "Escape") cancel();
|
||||
}}
|
||||
/>
|
||||
<button className="knowledge-category-save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>Готово</button>
|
||||
<button className="knowledge-category-cancel" type="button" onClick={cancel}>Отмена</button>
|
||||
<button className="knowledge-category-save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>{t("common.done")}</button>
|
||||
<button className="knowledge-category-cancel" type="button" onClick={cancel}>{t("common.cancel")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -247,7 +245,7 @@ export function KnowledgeCategoriesPage({
|
||||
<span className="knowledge-category-grip" />
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="Название категории"
|
||||
placeholder={t("shared.category_name")}
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -255,35 +253,34 @@ export function KnowledgeCategoriesPage({
|
||||
if (event.key === "Escape") cancel();
|
||||
}}
|
||||
/>
|
||||
<button className="knowledge-category-save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>Готово</button>
|
||||
<button className="knowledge-category-cancel" type="button" onClick={cancel}>Отмена</button>
|
||||
<button className="knowledge-category-save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>{t("common.done")}</button>
|
||||
<button className="knowledge-category-cancel" type="button" onClick={cancel}>{t("common.cancel")}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className="knowledge-categories-foot">
|
||||
<button type="button" onClick={() => { setEditingId(null); setCreateParentId(null); setDraftName(""); }}>
|
||||
<Icon name="plus" size={14} strokeWidth={2} />Категория верхнего уровня
|
||||
</button>
|
||||
<Icon name="plus" size={14} strokeWidth={2} />{t("ai.top_level_category")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="knowledge-categories-side">
|
||||
<div className="knowledge-rules-card">
|
||||
<strong>Правила</strong>
|
||||
<strong>{t("ai.rules")}</strong>
|
||||
<ul>
|
||||
<li>Имя уникально внутри родителя.</li>
|
||||
<li>Категорию нельзя перенести внутрь себя.</li>
|
||||
<li>Удаление — только для пустой категории без вложенных.</li>
|
||||
<li>«Без категории» системная: не переименовывается и не удаляется.</li>
|
||||
<li>{t("ai.name_unique_within_its_parent")}</li>
|
||||
<li>{t("ai.category_cannot_moved_inside_itself")}</li>
|
||||
<li>{t("ai.only_empty_category_with_nothing")}</li>
|
||||
<li>{t("ai.no_category_system_one_cannot")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{(blocked || error) && (
|
||||
<div className="knowledge-warning-card">
|
||||
<Icon name="alert" size={16} strokeWidth={2} />
|
||||
<span>
|
||||
{error || `«${blocked?.name}» удалить нельзя: ${blocked?.reason}. Сначала перенесите содержимое.`}
|
||||
{error || t("ai.cannot_delete_named", { name: blocked?.name ?? "", reason: blocked?.reason ?? "" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -296,10 +293,10 @@ export function KnowledgeCategoriesPage({
|
||||
onClose={() => setDeleting(null)}
|
||||
tone="danger"
|
||||
icon="trash"
|
||||
title="Удалить категорию?"
|
||||
description={deleting ? `Категория «${deleting.name}» пуста и будет удалена.` : ""}
|
||||
title={t("shared.delete_category")}
|
||||
description={deleting ? t("ai.category_empty_will_delete", { name: deleting.name }) : ""}
|
||||
actions={<>
|
||||
<Button variant="secondary" onClick={() => setDeleting(null)}>Отмена</Button>
|
||||
<Button variant="secondary" onClick={() => setDeleting(null)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
disabled={busy}
|
||||
@@ -308,9 +305,7 @@ export function KnowledgeCategoriesPage({
|
||||
setDeleting(null);
|
||||
if (target) void run(() => deleteKnowledgeCategory(target.id));
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
>{t("common.delete")}</Button>
|
||||
</>}
|
||||
/>
|
||||
</section>
|
||||
|
||||
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user