From 3672463363bc1cecd7f9c5992608186882e761e9 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 6 Sep 2026 18:57:17 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(ui):=20=D0=90=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D0=B8=20=D0=A1=D0=BE=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=B4=D0=BD=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B4=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D0=B9=D0=BD-=D0=B1=D0=B0=D0=B7=D0=BB=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=D1=83=20v2,=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Агенты (кадры G1–G5, S1): список с порядком активные → на паузе → отключённые, цвет плитки по id агента, подключения и знания на карточке, диалог выбора знаний, стоимость и лимиты. Сотрудники (кадры E1–E10): список, карточка, панель создания в четыре шага, пароль первичного доступа (показать или письмом), завершение сессий, передача владения. Бэкенд: employee_password.py, reset-password и revoke-sessions вместо заглушек, password_changed_at. Общие контролы: один поиск и один фильтр-селект на всё приложение — SearchInput с реакцией на фокус и клавишей «/», FilterDropdown на antd Dropdown вместо нативного select; копии в контактах и списке диалогов удалены. Ширина списков Сотрудники/Контакты/Агенты ограничена колонкой 1220px как в макете вместо растяжки на всё окно. Тем же коммитом уходит незакоммиченная работа предыдущих сессий этого дня: Профиль отдельной страницей, субменю Настроек, интеграции, метаданные сессий и TOTP, объединение контактов. Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/ai/agent_card.py | 84 +- apps/backend/chatballs/ai/agent_card_views.py | 8 +- apps/backend/chatballs/ai/test_agent_cards.py | 58 + .../conversations/chat_extras_views.py | 4 +- .../chatballs/conversations/clients.py | 96 +- .../chatballs/conversations/contacts_merge.py | 127 ++ .../backend/chatballs/conversations/ingest.py | 11 +- ...16_connectionidentity_phone_verified_at.py | 15 + .../0017_conversation_note_author.py | 23 + .../migrations/0018_contact_merge.py | 38 + .../backend/chatballs/conversations/models.py | 51 + .../conversations/reporting_views.py | 55 + .../chatballs/conversations/serializers.py | 1 + .../conversations/test_contacts_merge.py | 203 +++ apps/backend/chatballs/conversations/urls.py | 1 + .../identity/administration_payloads.py | 27 + .../chatballs/identity/auth/__init__.py | 2 + .../backend/chatballs/identity/auth/common.py | 3 + .../chatballs/identity/auth/password_reset.py | 4 +- .../chatballs/identity/auth/profile.py | 18 +- .../chatballs/identity/auth/sessions.py | 2 + apps/backend/chatballs/identity/auth/totp.py | 9 +- apps/backend/chatballs/identity/auth_urls.py | 1 + .../demo_seed/data/conversations.json | 15 + .../demo_seed/loaders/conversations.py | 21 + .../chatballs/identity/demo_seed/service.py | 13 +- .../chatballs/identity/employee_password.py | 95 ++ .../identity/employee_security_views.py | 51 +- .../chatballs/identity/employee_support.py | 6 + .../chatballs/identity/employee_views.py | 28 +- apps/backend/chatballs/identity/middleware.py | 21 + .../0025_humanuser_totp_last_used_at.py | 15 + .../0026_humanuser_password_changed_at.py | 15 + apps/backend/chatballs/identity/models.py | 5 + apps/backend/chatballs/identity/sessions.py | 131 +- .../identity/test_administration_api.py | 2 + .../identity/test_employee_password.py | 126 ++ .../chatballs/identity/test_sessions_meta.py | 30 + apps/backend/chatballs/identity/tests.py | 33 +- .../chatballs/integrations/serializers.py | 14 +- .../migrations/0026_contact_merge_rls.py | 47 + .../chatballs_backend/settings_base.py | 1 + apps/internal-ui/src/App.tsx | 4 +- apps/internal-ui/src/auth/access.test.ts | 5 +- apps/internal-ui/src/auth/access.ts | 5 +- .../administration/OrganizationLogoField.tsx | 63 +- .../OrganizationSettingsForm.tsx | 45 +- .../src/features/administration/model.ts | 2 + .../src/features/administration/styles.css | 81 +- .../src/features/agents/AgentDetailPage.tsx | 615 ++++---- .../features/agents/AgentKnowledgeDialog.tsx | 141 ++ .../src/features/agents/AgentsPage.tsx | 199 +-- .../src/features/agents/connection-status.ts | 13 - apps/internal-ui/src/features/agents/model.ts | 155 +- .../src/features/agents/styles.css | 1311 +++++++++++++++-- .../ai/knowledge/KnowledgeDetailSidebar.tsx | 2 +- .../features/ai/knowledge/styles-library.css | 7 - apps/internal-ui/src/features/ai/model.ts | 2 +- apps/internal-ui/src/features/ai/styles.css | 4 - .../conversations/ConversationWorkspace.tsx | 11 +- .../features/conversations/DialogControls.tsx | 2 +- .../src/features/conversations/DialogList.tsx | 9 +- .../src/features/conversations/model.ts | 21 +- .../employees/EmployeeCreateDrawer.tsx | 193 ++- .../employees/EmployeeDetailHeader.tsx | 56 +- .../features/employees/EmployeeDetailPage.tsx | 80 +- .../features/employees/EmployeeDetailRail.tsx | 42 +- .../employees/EmployeeDetailSections.tsx | 19 - .../employees/EmployeeIdentitySections.tsx | 90 +- .../employees/EmployeePasswordDialog.tsx | 83 ++ .../employees/EmployeeSecuritySections.tsx | 84 +- .../src/features/employees/EmployeeTable.tsx | 143 +- .../features/employees/EmployeesFilters.tsx | 34 +- .../features/employees/EmployeesListPage.tsx | 76 +- .../employees/OwnershipTransferModal.tsx | 134 +- .../internal-ui/src/features/employees/api.ts | 48 + .../src/features/employees/model.ts | 39 +- .../src/features/employees/styles-access.css | 44 - .../src/features/employees/styles-create.css | 619 +++++++- .../src/features/employees/styles-detail.css | 762 ++++++++-- .../src/features/employees/styles-list.css | 377 ++++- .../features/employees/styles-ownership.css | 35 - .../src/features/employees/styles.css | 2 - .../integrations/ConnectionsTable.tsx | 14 +- .../integrations/IntegrationsSection.tsx | 76 +- .../features/integrations/ProvidersTable.tsx | 2 +- .../src/features/integrations/model.ts | 2 + .../src/features/integrations/rows.tsx | 23 +- .../src/features/integrations/styles.css | 47 +- .../profile/ProfileAppearanceCard.tsx | 41 +- .../src/features/profile/ProfileHeader.tsx | 35 +- .../src/features/profile/ProfileInfoForm.tsx | 23 +- .../profile/ProfileNotificationsCard.tsx | 47 +- .../src/features/profile/ProfilePage.tsx | 110 +- .../features/profile/ProfilePasswordForm.tsx | 11 +- .../features/profile/ProfilePhotoField.tsx | 86 ++ .../features/profile/ProfileSessionsCard.tsx | 51 +- .../src/features/profile/ProfileTotpCard.tsx | 33 +- .../internal-ui/src/features/profile/model.ts | 31 + .../src/features/profile/styles.css | 1047 +++++++++---- .../src/features/sales/SalesClientsPage.tsx | 19 +- .../client-detail/SalesClientConsentTab.tsx | 6 - .../client-detail/SalesClientDetailPage.tsx | 25 +- .../client-detail/SalesClientDialogsTab.tsx | 27 +- .../sales/client-detail/SalesClientHeader.tsx | 95 +- .../SalesClientIdentitiesTab.tsx | 129 +- .../client-detail/SalesClientMergeDialog.tsx | 114 ++ .../client-detail/SalesClientOverviewTab.tsx | 100 +- .../sales/client-detail/SalesClientTabs.tsx | 6 - .../src/features/sales/client-detail/model.ts | 193 ++- .../features/sales/client-detail/styles.css | 985 ++++++++++++- .../sales/client-detail/styles/base.css | 55 - .../sales/client-detail/styles/content.css | 177 --- .../sales/client-detail/styles/header.css | 136 -- .../sales/client-detail/styles/overview.css | 146 -- .../sales/client-detail/styles/responsive.css | 27 - .../sales/client-detail/styles/tables.css | 85 -- .../sales/client-detail/styles/tabs.css | 23 - .../sales/client-detail/useClientDetail.ts | 22 +- .../features/sales/clients/SalesClientRow.tsx | 65 +- .../sales/clients/SalesClientsEmpty.tsx | 15 + .../sales/clients/SalesClientsFilters.tsx | 86 +- .../sales/clients/SalesClientsHeader.tsx | 15 +- .../sales/clients/SalesClientsPagination.tsx | 21 +- .../sales/clients/SalesClientsTable.tsx | 72 +- .../sales/clients/exportClientsCsv.ts | 6 +- .../src/features/sales/clients/model.ts | 122 +- .../src/features/sales/clients/styles.css | 637 ++++---- .../features/sales/clients/useSalesClients.ts | 27 +- .../sales/dialogs/context/ClientContext.tsx | 21 +- .../src/features/sales/dialogs/list.css | 17 +- .../settings/CommunicationSettingsCard.tsx | 14 +- .../src/features/settings/DemoDataCard.tsx | 61 +- .../features/settings/GroupsSettingsCard.tsx | 57 +- .../src/features/settings/SettingsPage.tsx | 205 ++- .../features/settings/StorageSettingsCard.tsx | 20 +- .../src/features/settings/sections.ts | 86 ++ .../src/features/settings/styles.css | 918 ++++++++++-- .../src/features/settings/useIntegrations.ts | 36 + .../src/layout/LaunchChecklist.tsx | 9 +- apps/internal-ui/src/layout/Shell.tsx | 27 +- .../src/layout/ShellRouteContent.tsx | 16 +- apps/internal-ui/src/layout/Sidebar.tsx | 13 +- apps/internal-ui/src/router.test.ts | 28 +- apps/internal-ui/src/router.ts | 16 +- apps/internal-ui/src/shared/appearance.ts | 7 +- apps/internal-ui/src/shared/badges.tsx | 16 +- .../src/shared/design-components.css | 23 - apps/internal-ui/src/shared/form-controls.css | 25 +- apps/internal-ui/src/shared/icons.tsx | 52 +- apps/internal-ui/src/shared/menu.css | 17 + apps/internal-ui/src/shared/providers.ts | 10 + .../internal-ui/src/shared/table-controls.css | 86 +- apps/internal-ui/src/shared/ui-controls.css | 143 +- apps/internal-ui/src/shared/ui-controls.tsx | 108 +- apps/internal-ui/src/shared/ui.tsx | 7 +- apps/internal-ui/src/shared/utils.ts | 47 + apps/internal-ui/src/styles/base.css | 54 +- apps/internal-ui/src/types.ts | 6 + apps/internal-ui/src/useRouteNavigation.ts | 20 +- .../baseline/Chatballs Baseline v2/README.md | 116 ++ .../Агенты Baseline.dc.html | 315 ++++ .../Админ Baseline.dc.html | 11 +- .../Контакты Baseline.dc.html | 325 ++++ .../Настройки Baseline.dc.html | 368 +++++ .../Порталы Baseline.dc.html | 766 ++++++++++ .../Профиль Baseline.dc.html | 279 ++++ .../Сотрудники Baseline.dc.html | 432 ++++++ .../Чат сотрудника Baseline.dc.html | 9 +- 169 files changed, 13683 insertions(+), 3385 deletions(-) create mode 100644 apps/backend/chatballs/conversations/contacts_merge.py create mode 100644 apps/backend/chatballs/conversations/migrations/0016_connectionidentity_phone_verified_at.py create mode 100644 apps/backend/chatballs/conversations/migrations/0017_conversation_note_author.py create mode 100644 apps/backend/chatballs/conversations/migrations/0018_contact_merge.py create mode 100644 apps/backend/chatballs/conversations/test_contacts_merge.py create mode 100644 apps/backend/chatballs/identity/employee_password.py create mode 100644 apps/backend/chatballs/identity/middleware.py create mode 100644 apps/backend/chatballs/identity/migrations/0025_humanuser_totp_last_used_at.py create mode 100644 apps/backend/chatballs/identity/migrations/0026_humanuser_password_changed_at.py create mode 100644 apps/backend/chatballs/identity/test_employee_password.py create mode 100644 apps/backend/chatballs/identity/test_sessions_meta.py create mode 100644 apps/backend/chatballs/tenancy/migrations/0026_contact_merge_rls.py create mode 100644 apps/internal-ui/src/features/agents/AgentKnowledgeDialog.tsx delete mode 100644 apps/internal-ui/src/features/agents/connection-status.ts delete mode 100644 apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx create mode 100644 apps/internal-ui/src/features/employees/EmployeePasswordDialog.tsx create mode 100644 apps/internal-ui/src/features/employees/api.ts delete mode 100644 apps/internal-ui/src/features/employees/styles-access.css delete mode 100644 apps/internal-ui/src/features/employees/styles-ownership.css create mode 100644 apps/internal-ui/src/features/profile/ProfilePhotoField.tsx create mode 100644 apps/internal-ui/src/features/profile/model.ts delete mode 100644 apps/internal-ui/src/features/sales/client-detail/SalesClientConsentTab.tsx create mode 100644 apps/internal-ui/src/features/sales/client-detail/SalesClientMergeDialog.tsx delete mode 100644 apps/internal-ui/src/features/sales/client-detail/SalesClientTabs.tsx delete mode 100644 apps/internal-ui/src/features/sales/client-detail/styles/base.css delete mode 100644 apps/internal-ui/src/features/sales/client-detail/styles/content.css delete mode 100644 apps/internal-ui/src/features/sales/client-detail/styles/header.css delete mode 100644 apps/internal-ui/src/features/sales/client-detail/styles/overview.css delete mode 100644 apps/internal-ui/src/features/sales/client-detail/styles/responsive.css delete mode 100644 apps/internal-ui/src/features/sales/client-detail/styles/tables.css delete mode 100644 apps/internal-ui/src/features/sales/client-detail/styles/tabs.css create mode 100644 apps/internal-ui/src/features/sales/clients/SalesClientsEmpty.tsx create mode 100644 apps/internal-ui/src/features/settings/sections.ts create mode 100644 apps/internal-ui/src/features/settings/useIntegrations.ts create mode 100644 design/baseline/Chatballs Baseline v2/Агенты Baseline.dc.html create mode 100644 design/baseline/Chatballs Baseline v2/Контакты Baseline.dc.html create mode 100644 design/baseline/Chatballs Baseline v2/Настройки Baseline.dc.html create mode 100644 design/baseline/Chatballs Baseline v2/Порталы Baseline.dc.html create mode 100644 design/baseline/Chatballs Baseline v2/Профиль Baseline.dc.html create mode 100644 design/baseline/Chatballs Baseline v2/Сотрудники Baseline.dc.html diff --git a/apps/backend/chatballs/ai/agent_card.py b/apps/backend/chatballs/ai/agent_card.py index 5db3a08..353b93c 100644 --- a/apps/backend/chatballs/ai/agent_card.py +++ b/apps/backend/chatballs/ai/agent_card.py @@ -9,7 +9,7 @@ from __future__ import annotations from django.core.exceptions import ValidationError from django.db import transaction -from django.db.models import Count, Q, QuerySet +from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When from django.utils.text import slugify from chatballs.ai.models import AIAgent, AIAgentStatus @@ -30,15 +30,27 @@ def agent_cards_for_context(context: TenantContext) -> QuerySet[Channel]: return ( Channel.objects.filter(organization_id=context.organization_id) .select_related("group", "ai_agent", "ai_agent__provider_integration") - .prefetch_related("connections", "ai_agent__knowledge_items", "ai_agent__portal_articles") + .prefetch_related( + "connections__web_chat_widget", + "ai_agent__knowledge_items", + "ai_agent__portal_articles__portal", + "ai_agent__portal_articles__published_revision", + ) .annotate( open_conversations_count=Count( "conversations", filter=Q(conversations__lifecycle=LifecycleState.OPEN), distinct=True, - ) + ), + # Кадр G1: сверху отвечающие агенты, ниже «Без AI», выключенные — в конце. + status_rank=Case( + When(is_active=False, then=Value(2)), + When(ai_agent__status=AIAgentStatus.ACTIVE, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ), ) - .order_by("name") + .order_by("status_rank", "name") ) @@ -46,19 +58,57 @@ def agent_card_for_context(*, context: TenantContext, agent_id: int) -> Channel: return agent_cards_for_context(context).get(id=agent_id) +def _connection_payload(connection) -> dict[str, object]: + """Подключение в карточке агента. + + Подпись строки собирается на фронте из этих полей: у Telegram — имя бота, + у Web — домен сайта и публичный ключ виджета (из него собирается код + вставки), у Email — адрес ящика. + """ + config = connection.config or {} + payload = { + "id": connection.id, + "provider": connection.provider, + "name": connection.name, + "status": connection.status, + "botUsername": config.get("bot_username", ""), + "email": config.get("email", ""), + "allowedOrigins": config.get("allowed_domains", []), + "widgetPublicKey": "", + } + if connection.provider == "WEB": + from chatballs.webchat.widgets import widget_for_integration + + widget = widget_for_integration(connection) + payload["widgetPublicKey"] = widget.public_key if widget is not None else "" + return payload + + def _connections_payload(channel: Channel) -> list[dict[str, object]]: return [ - { - "id": connection.id, - "provider": connection.provider, - "name": connection.name, - "status": connection.status, - } + _connection_payload(connection) for connection in sorted(channel.connections.all(), key=lambda item: item.id) ] -def agent_card_payload(channel: Channel) -> dict[str, object]: +def knowledge_total_for_organization(organization_id: int) -> int: + """Сколько всего материалов можно выбрать агенту — знаний библиотеки и + опубликованных статей порталов («4 из 18» в шапке блока «Знания»). + Библиотека общая для организации (ADR-HUB-0041 §8), поэтому число одно + на всех агентов — список считает его один раз.""" + from chatballs.ai.models import Knowledge + from chatballs.support_portals.models import PortalArticle + from chatballs.support_portals.statuses import ArticleStatus + + return ( + Knowledge.objects.filter(organization_id=organization_id).count() + + PortalArticle.objects.filter( + organization_id=organization_id, status=ArticleStatus.PUBLISHED + ).count() + ) + + +def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]: agent: AIAgent = channel.ai_agent connections = _connections_payload(channel) open_count = getattr(channel, "open_conversations_count", None) @@ -72,6 +122,8 @@ def agent_card_payload(channel: Channel) -> dict[str, object]: "isActive": channel.is_active, "groupId": channel.group_id, "groupName": channel.group.name if channel.group_id else None, + # Цвет группы задаётся в настройках — точка у названия (кадры G1/G3). + "groupColor": channel.group.color if channel.group_id else "", "aiStatus": agent.status, "model": agent.model, "providerIntegrationId": agent.provider_integration_id, @@ -81,9 +133,17 @@ def agent_card_payload(channel: Channel) -> dict[str, object]: "tone": agent.tone, "instructions": agent.instructions, "knowledge": [ - {"id": item.id, "title": item.title, "isEnabled": item.is_enabled} + { + "id": item.id, + "title": item.title, + "isEnabled": item.is_enabled, + "updatedAt": item.updated_at.isoformat(), + } for item in agent.knowledge_items.all() ], + "knowledgeTotal": knowledge_total + if knowledge_total is not None + else knowledge_total_for_organization(channel.organization_id), "portalArticles": [ agent_portal_article_payload(article) for article in agent.portal_articles.all() diff --git a/apps/backend/chatballs/ai/agent_card_views.py b/apps/backend/chatballs/ai/agent_card_views.py index c6c7a6c..68252d6 100644 --- a/apps/backend/chatballs/ai/agent_card_views.py +++ b/apps/backend/chatballs/ai/agent_card_views.py @@ -69,13 +69,17 @@ class AgentCardListView(APIView): cards = cards.filter(group_id=int(group)) except ValueError: return Response({"detail": "group must be an id or none"}, status=400) - from chatballs.ai.agent_card import ensure_channel_agent + from chatballs.ai.agent_card import ( + ensure_channel_agent, + knowledge_total_for_organization, + ) + total = knowledge_total_for_organization(request.tenant_context.organization_id) items = [] for channel in cards: # Страховка для каналов, созданных в обход мастера. ensure_channel_agent(channel) - items.append(agent_card_payload(channel)) + items.append(agent_card_payload(channel, knowledge_total=total)) return Response({"items": items}) def post(self, request: Request) -> Response: diff --git a/apps/backend/chatballs/ai/test_agent_cards.py b/apps/backend/chatballs/ai/test_agent_cards.py index 330eae7..1e93154 100644 --- a/apps/backend/chatballs/ai/test_agent_cards.py +++ b/apps/backend/chatballs/ai/test_agent_cards.py @@ -98,6 +98,64 @@ class AgentCardListDetailTests(AgentCardTestCase): [item["name"] for item in ungrouped.json()["items"]], ["Без группы"] ) + def test_list_puts_answering_agents_first_and_disabled_last(self) -> None: + """Кадр G1: сверху агенты с AI, ниже «Без AI», выключенные — в конце.""" + from chatballs.ai.models import AIAgent, AIAgentStatus + from chatballs.channels.models import Channel + + draft_id = self.create_agent(name="Бета").json()["agent"]["id"] + answering_id = self.create_agent(name="Альфа").json()["agent"]["id"] + AIAgent.objects.filter(channel_id=answering_id).update(status=AIAgentStatus.ACTIVE) + off_id = self.create_agent(name="Ааа выключенный").json()["agent"]["id"] + Channel.objects.filter(id=off_id).update(is_active=False) + + listed = self.client.get("/api/v1/agents/").json()["items"] + order = [item["id"] for item in listed] + self.assertEqual(order[0], answering_id) + self.assertEqual(order[-1], off_id) + self.assertLess(order.index(draft_id), order.index(off_id)) + + def test_card_carries_group_color_and_knowledge_total(self) -> None: + """Кадры G1/G3: точка группы красится цветом группы, а в шапке блока + «Знания» стоит «N из M» — оба значения приходят с карточкой.""" + from chatballs.ai.models import Knowledge, KnowledgeCategory + + self.operators.color = "#2aa876" + self.operators.save(update_fields=["color"]) + category = KnowledgeCategory.objects.filter( + organization=self.organization + ).first() or KnowledgeCategory.objects.create( + organization=self.organization, name="Общие" + ) + Knowledge.objects.create( + organization=self.organization, category=category, title="Прайс" + ) + + card = self.client.get(f"/api/v1/agents/{self.card_id}/").json()["agent"] + self.assertEqual(card["groupColor"], "#2aa876") + self.assertEqual(card["knowledgeTotal"], 1) + self.assertEqual(card["knowledge"], []) + + def test_connection_row_carries_fields_for_its_subtitle(self) -> None: + """Кадры G3/G4: подпись строки подключения и код вставки виджета + собираются из полей подключения.""" + integration = Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="Бот", + channel_id=self.card_id, + config={"bot_username": "atelie_nord_bot"}, + ) + + card = self.client.get(f"/api/v1/agents/{self.card_id}/").json()["agent"] + connection = card["connections"][0] + self.assertEqual(connection["id"], integration.id) + self.assertEqual(connection["botUsername"], "atelie_nord_bot") + self.assertEqual(connection["email"], "") + self.assertEqual(connection["allowedOrigins"], []) + self.assertEqual(connection["widgetPublicKey"], "") + def test_legacy_channel_without_agent_gets_draft_agent_in_list(self) -> None: channel = Channel.objects.create( organization=self.organization, code="legacy", name="Legacy" diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index e45e674..4ec2720 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -123,7 +123,9 @@ class ConversationNoteView(ConversationViewBase): if len(note) > 4000: return Response({"detail": "Заметка длиннее 4000 символов"}, status=400) conversation.note = note - conversation.save(update_fields=["note"]) + conversation.note_author = request.user if note else None + conversation.note_updated_at = timezone.now() if note else None + conversation.save(update_fields=["note", "note_author", "note_updated_at"]) return Response( { "conversation": conversation_payload( diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index 380c8f5..94cd2ff 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -11,10 +11,12 @@ from django.db.models import Prefetch, Q from chatballs.conversations.models import ( ConnectionIdentity, Contact, + ContactMerge, Conversation, ControlMode, LifecycleState, ) +from chatballs.identity.avatars import user_avatar_url_in from chatballs.identity.models import AuditEvent # Короткие коды для UI (совпадают с фронтовыми справочниками). @@ -35,6 +37,10 @@ AUDIT_LABELS = { } +def _actor_name(user) -> str: + return (user.full_name or user.email) if user is not None else "" + + def _mode(latest: Conversation) -> str: if latest.lifecycle != LifecycleState.OPEN: return "closed" @@ -47,10 +53,10 @@ def _mode(latest: Conversation) -> str: def clients_overview(organization_id: int) -> list[dict]: conversation_qs = Conversation.objects.select_related( - "channel", "channel__product", "connection" + "channel", "channel__product", "connection", "assigned_operator" ).order_by("-last_activity_at") identity_qs = ConnectionIdentity.objects.select_related("connection") - contacts = Contact.objects.filter(organization_id=organization_id).prefetch_related( + contacts = Contact.objects.filter(organization_id=organization_id, merged_into__isnull=True).prefetch_related( Prefetch( "conversations", queryset=conversation_qs, @@ -64,6 +70,7 @@ def clients_overview(organization_id: int) -> list[dict]: continue # клиенты — те, кто писал channels: set[str] = set() products: dict[str, str] = {} + agents: dict[int, dict[str, object]] = {} open_dialogs = 0 for conversation in conversations: provider = conversation.connection.provider if conversation.connection_id else None @@ -71,6 +78,11 @@ def clients_overview(organization_id: int) -> list[dict]: channels.add(PROVIDER_CODE[provider]) if conversation.channel.product_id: products[conversation.channel.product.code] = conversation.channel.product.name + # Агент = карточка канала обработки: по нему фильтруется список (кадр K1). + agents.setdefault( + conversation.channel_id, + {"id": conversation.channel_id, "code": conversation.channel.code, "name": conversation.channel.name}, + ) if conversation.lifecycle == LifecycleState.OPEN: open_dialogs += 1 latest = conversations[0] @@ -97,6 +109,11 @@ def clients_overview(organization_id: int) -> list[dict]: "totalDialogs": len(conversations), "lastActivityAt": latest.last_activity_at.isoformat(), "mode": _mode(latest), + # Колонка «Последний диалог» (кадр K1): кто ведёт — агент или сотрудник. + "lastAgentName": latest.channel.name, + "lastAgentCode": latest.channel.code, + "lastAssignee": _actor_name(latest.assigned_operator), + "agents": sorted(agents.values(), key=lambda item: str(item["name"])), } ) rows.sort(key=lambda row: row["lastActivityAt"], reverse=True) @@ -111,7 +128,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict: contact = Contact.objects.get(organization_id=organization_id, id=contact_id) conversation_qs = Conversation.objects.filter( organization_id=organization_id, contact=contact - ).select_related("channel", "channel__product", "connection") + ).select_related("channel", "channel__product", "connection", "group", "assigned_operator", "note_author") conversations = list(conversation_qs.order_by("-last_activity_at")) if not conversations: raise Contact.DoesNotExist @@ -128,14 +145,29 @@ def client_detail(organization_id: int, contact_id: int) -> dict: products[conversation.channel.product.code] = conversation.channel.product.name if conversation.lifecycle == LifecycleState.OPEN: open_dialogs += 1 + # Тема диалога — первое сообщение, превью — последнее (кадр K4). + first = conversation.messages.order_by("created_at").first() last = conversation.messages.order_by("-created_at").first() - title = (last.text.replace("\n", " ")[:80] if last and last.text else conversation.channel.name) + title = (first.text.replace("\n", " ")[:80] if first and first.text else conversation.channel.name) + preview = (last.text.replace("\n", " ")[:120] if last and last.text else "") dialogs.append( { "id": conversation.id, "title": title, + "preview": preview, "channelName": conversation.channel.name, + "agentName": conversation.channel.name, + "agentId": conversation.channel_id, + "agentCode": conversation.channel.code, + "groupName": conversation.group.name if conversation.group_id else "", + "groupColor": conversation.group.color if conversation.group_id else "", + "assignee": _actor_name(conversation.assigned_operator), + "assigneeAvatarUrl": user_avatar_url_in(conversation.assigned_operator, organization_id), + "note": conversation.note, + "noteAuthor": _actor_name(conversation.note_author), + "noteUpdatedAt": conversation.note_updated_at.isoformat() if conversation.note_updated_at else None, "provider": provider, + "mode": _mode(conversation), "status": _dialog_status(conversation), "active": conversation.lifecycle == LifecycleState.OPEN, "lastActivityAt": conversation.last_activity_at.isoformat(), @@ -151,8 +183,11 @@ def client_detail(organization_id: int, contact_id: int) -> dict: if identity.connection.provider == "EMAIL" else identity.display_name or identity.external_user_id ), + "externalUserId": identity.external_user_id, "username": identity.username, "createdAt": identity.created_at.isoformat(), + # Подтверждённой считается идентичность, отдавшая телефон (ADR-HUB-0006). + "phoneVerifiedAt": identity.phone_verified_at.isoformat() if identity.phone_verified_at else None, } for identity in identity_qs.order_by("created_at") ] @@ -214,4 +249,57 @@ def client_detail(organization_id: int, contact_id: int) -> dict: "identities": identities, "activity": activity[:8], "audit": audit, + "duplicate": _duplicate_candidate(organization_id, contact), + "merges": _merges(organization_id, contact), + } + + +def _merges(organization_id: int, contact: Contact) -> list[dict]: + """Действующие объединения этого контакта — их можно разъединить.""" + rows = ( + ContactMerge.objects.filter(organization_id=organization_id, target=contact, reverted_at__isnull=True) + .select_related("source", "actor") + .order_by("-created_at") + ) + return [ + { + "id": row.id, + "sourceId": row.source_id, + "sourceName": row.source.name or "Гость", + "sourceCid": f"CUS-{row.source_id}", + "reason": row.reason, + "actor": _actor_name(row.actor), + "at": row.created_at.isoformat(), + "identities": len(row.moved_identity_ids), + "conversations": len(row.moved_conversation_ids), + } + for row in rows + ] + + +def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None: + """Другой контакт с тем же телефоном. Автоматически ничего не объединяем + (ADR-HUB-0006) — это только предложение владельцу.""" + if not contact.phone: + return None + other = ( + Contact.objects.filter(organization_id=organization_id, phone=contact.phone, merged_into__isnull=True) + .exclude(id=contact.id) + .prefetch_related("identities__connection", "conversations") + .first() + ) + if other is None: + return None + identities = list(other.identities.all()) + return { + "id": other.id, + "cid": f"CUS-{other.id}", + "name": other.name or "Гость", + "avatarUrl": other.avatar_url, + "dialogs": other.conversations.count(), + "sources": sorted({identity.connection.provider for identity in identities}), + "phone": other.phone, + # Однозначным совпадение считается, только если телефон подтверждён + # подключением хотя бы у одной стороны (ADR-HUB-0006). + "phoneVerified": any(identity.phone_verified_at is not None for identity in identities), } diff --git a/apps/backend/chatballs/conversations/contacts_merge.py b/apps/backend/chatballs/conversations/contacts_merge.py new file mode 100644 index 0000000..63fffaa --- /dev/null +++ b/apps/backend/chatballs/conversations/contacts_merge.py @@ -0,0 +1,127 @@ +"""Объединение и разъединение контактов (ADR-HUB-0006). + +Автоматически идентичности разных подключений не объединяются. Объединение — +ручная операция владельца: требует причины, переносит идентичности и диалоги, +полностью аудируется и разворачивается обратно. Исходный контакт не удаляется, +поэтому разъединение возвращает ровно то, что переехало. +""" + +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils import timezone + +from chatballs.conversations.models import ConnectionIdentity, ContactMerge, Contact, Conversation +from chatballs.identity.audit import record_audit_event + +# Поля карточки, которые дозаполняются из исходного контакта, если у целевого +# они пустые. Что именно заполнили — запоминаем, чтобы очистить при разъединении. +CARD_FIELDS = ("name", "phone", "avatar_url", "description", "company", "city") +MIN_REASON_LENGTH = 5 + + +def _clean_reason(reason: str) -> str: + cleaned = (reason or "").strip() + if len(cleaned) < MIN_REASON_LENGTH: + raise ValidationError("Укажите причину объединения — она попадёт в журнал действий") + return cleaned[:2000] + + +@transaction.atomic +def merge_contacts(*, organization, target_id: int, source_id: int, reason: str, actor, request=None) -> ContactMerge: + """Перенести идентичности и диалоги source в target.""" + cleaned = _clean_reason(reason) + if target_id == source_id: + raise ValidationError("Нельзя объединить контакт с самим собой") + 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 + if source.merged_into_id is not None or target.merged_into_id is not None: + raise ValidationError("Контакт уже объединён с другим — сначала разъедините") + + identity_ids = list(ConnectionIdentity.objects.filter(contact=source).values_list("id", flat=True)) + conversation_ids = list( + Conversation.objects.filter(organization=organization, contact=source).values_list("id", flat=True) + ) + ConnectionIdentity.objects.filter(id__in=identity_ids).update(contact=target) + Conversation.objects.filter(id__in=conversation_ids).update(contact=target) + + filled: list[str] = [] + for field in CARD_FIELDS: + if not getattr(target, field) and getattr(source, field): + setattr(target, field, getattr(source, field)) + filled.append(field) + if filled: + target.save(update_fields=filled) + + source.merged_into = target + source.save(update_fields=["merged_into"]) + + merge = ContactMerge.objects.create( + organization=organization, + target=target, + source=source, + reason=cleaned, + actor=actor, + moved_identity_ids=identity_ids, + moved_conversation_ids=conversation_ids, + filled_fields=filled, + ) + record_audit_event( + action="contacts.merged", + actor=actor, + organization=organization, + object_type="Contact", + object_id=str(target.id), + payload={ + "sourceId": source.id, + "reason": cleaned, + "identities": len(identity_ids), + "conversations": len(conversation_ids), + }, + request=request, + ) + return merge + + +@transaction.atomic +def revert_merge(*, organization, merge_id: int, reason: str, actor, request=None) -> ContactMerge: + """Вернуть перенесённые идентичности и диалоги исходному контакту.""" + cleaned = _clean_reason(reason) + try: + merge = ContactMerge.objects.select_for_update().get(organization=organization, id=merge_id) + except ContactMerge.DoesNotExist as error: + raise ValidationError("Объединение не найдено") from error + if merge.reverted_at is not None: + raise ValidationError("Это объединение уже разъединено") + + source = merge.source + target = merge.target + ConnectionIdentity.objects.filter(id__in=merge.moved_identity_ids).update(contact=source) + Conversation.objects.filter(id__in=merge.moved_conversation_ids).update(contact=source) + if merge.filled_fields: + for field in merge.filled_fields: + setattr(target, field, "") + target.save(update_fields=list(merge.filled_fields)) + + source.merged_into = None + source.save(update_fields=["merged_into"]) + + merge.reverted_at = timezone.now() + merge.reverted_by = actor + merge.revert_reason = cleaned + merge.save(update_fields=["reverted_at", "reverted_by", "revert_reason"]) + + record_audit_event( + action="contacts.unmerged", + actor=actor, + organization=organization, + object_type="Contact", + object_id=str(target.id), + payload={"sourceId": source.id, "reason": cleaned, "mergeId": merge.id}, + request=request, + ) + return merge diff --git a/apps/backend/chatballs/conversations/ingest.py b/apps/backend/chatballs/conversations/ingest.py index ada3553..6d7f036 100644 --- a/apps/backend/chatballs/conversations/ingest.py +++ b/apps/backend/chatballs/conversations/ingest.py @@ -150,9 +150,14 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: identity.username = inbound.username identity.save(update_fields=["username"]) contact = identity.contact - if is_contact_share and contact.phone != inbound.phone: - contact.phone = inbound.phone - contact.save(update_fields=["phone"]) + if is_contact_share and inbound.phone: + if contact.phone != inbound.phone: + contact.phone = inbound.phone + contact.save(update_fields=["phone"]) + # Телефон подтвердило именно это подключение (ADR-HUB-0006). + if identity.phone_verified_at is None: + identity.phone_verified_at = timezone.now() + identity.save(update_fields=["phone_verified_at"]) # Аватар обновляем при каждом заходе: провайдер может сменить фото, # а контакт ещё не шарил телефон (is_contact_share=False). if inbound.avatar_url and contact.avatar_url != inbound.avatar_url: diff --git a/apps/backend/chatballs/conversations/migrations/0016_connectionidentity_phone_verified_at.py b/apps/backend/chatballs/conversations/migrations/0016_connectionidentity_phone_verified_at.py new file mode 100644 index 0000000..e2db9c9 --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0016_connectionidentity_phone_verified_at.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("conversations", "0015_message_attachment"), + ] + + operations = [ + migrations.AddField( + model_name="connectionidentity", + name="phone_verified_at", + field=models.DateTimeField(blank=True, db_default=None, null=True), + ), + ] diff --git a/apps/backend/chatballs/conversations/migrations/0017_conversation_note_author.py b/apps/backend/chatballs/conversations/migrations/0017_conversation_note_author.py new file mode 100644 index 0000000..72f7ef5 --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0017_conversation_note_author.py @@ -0,0 +1,23 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("conversations", "0016_connectionidentity_phone_verified_at"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="conversation", + name="note_author", + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name="conversation", + name="note_updated_at", + field=models.DateTimeField(blank=True, db_default=None, null=True), + ), + ] diff --git a/apps/backend/chatballs/conversations/migrations/0018_contact_merge.py b/apps/backend/chatballs/conversations/migrations/0018_contact_merge.py new file mode 100644 index 0000000..6995a99 --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0018_contact_merge.py @@ -0,0 +1,38 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("conversations", "0017_conversation_note_author"), + ("identity", "0025_humanuser_totp_last_used_at"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="contact", + name="merged_into", + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="merged_contacts", to="conversations.contact"), + ), + migrations.CreateModel( + name="ContactMerge", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("reason", models.TextField()), + ("moved_identity_ids", models.JSONField(blank=True, default=list)), + ("moved_conversation_ids", models.JSONField(blank=True, default=list)), + ("filled_fields", models.JSONField(blank=True, default=list)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("reverted_at", models.DateTimeField(blank=True, null=True)), + ("revert_reason", models.TextField(blank=True, default="")), + ("actor", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL)), + ("organization", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="contact_merges", to="identity.organization")), + ("reverted_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL)), + ("source", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="merges_out", to="conversations.contact")), + ("target", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="merges_in", to="conversations.contact")), + ], + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index cc038a3..24406ed 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -25,12 +25,49 @@ class Contact(models.Model): description = models.TextField(blank=True, default="") company = models.CharField(max_length=160, blank=True, default="") city = models.CharField(max_length=120, blank=True, default="") + # Контакт, в который этот был объединён (ADR-HUB-0006). Строка не удаляется: + # объединение обратимо, поэтому исходный контакт остаётся для разъединения. + merged_into = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + related_name="merged_contacts", + null=True, + blank=True, + ) created_at = models.DateTimeField(auto_now_add=True) def __str__(self) -> str: return self.name or f"contact:{self.id}" +class ContactMerge(models.Model): + """Журнал объединения контактов (ADR-HUB-0006). + + Хранит, что именно переехало, чтобы объединение можно было развернуть + обратно: перенесённые идентичности и диалоги и поля карточки, которые были + заполнены из исходного контакта. + """ + + organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contact_merges") + target = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="merges_in") + source = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="merges_out") + reason = models.TextField() + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") + moved_identity_ids = models.JSONField(default=list, blank=True) + moved_conversation_ids = models.JSONField(default=list, blank=True) + filled_fields = models.JSONField(default=list, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + reverted_at = models.DateTimeField(null=True, blank=True) + reverted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") + revert_reason = models.TextField(blank=True, default="") + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"merge:{self.source_id}->{self.target_id}" + + class ConnectionIdentity(TenantRelationModel): tenant_relation_fields = ("contact", "connection") # Устойчивая идентичность контакта внутри конкретного подключения (ADR-HUB-0006). @@ -40,6 +77,10 @@ class ConnectionIdentity(TenantRelationModel): display_name = models.CharField(max_length=255, blank=True) # Публичный логин в мессенджере (@username в TG/MAX); пустой, если не задан. username = models.CharField(max_length=128, blank=True) + # Когда подключение отдало подтверждённый телефон (кнопка «поделиться + # контактом»). Только такая идентичность считается подтверждённой + # (ADR-HUB-0006) — колонка «Статус» на вкладке «Идентификаторы». + phone_verified_at = models.DateTimeField(null=True, blank=True, db_default=None) created_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -142,6 +183,16 @@ class Conversation(models.Model): ) labels = models.ManyToManyField(ConversationLabel, blank=True, related_name="conversations") note = models.TextField(blank=True) + # Кто и когда оставил заметку — подпись «Анна Ким · 2 сен» на карточке + # контакта (дизайн-базлайн v2, кадр K3). + note_author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + note_updated_at = models.DateTimeField(null=True, blank=True, db_default=None) # «Удалить диалог» = архив (решение владельца): скрыт из списков, видят # только администраторы; данные не удаляются. archived_at = models.DateTimeField(null=True, blank=True) diff --git a/apps/backend/chatballs/conversations/reporting_views.py b/apps/backend/chatballs/conversations/reporting_views.py index e41a362..e468d9b 100644 --- a/apps/backend/chatballs/conversations/reporting_views.py +++ b/apps/backend/chatballs/conversations/reporting_views.py @@ -1,8 +1,12 @@ from rest_framework.request import Request from rest_framework.response import Response +from django.core.exceptions import ValidationError + from chatballs.conversations.clients import client_detail, clients_overview +from chatballs.conversations.contacts_merge import merge_contacts, revert_merge from chatballs.conversations.models import Contact +from chatballs.identity.models import EmployeeRole from chatballs.conversations.stats import sales_overview_stats from chatballs.conversations.view_base import ConversationViewBase from chatballs.identity.audit import record_audit_event @@ -63,3 +67,54 @@ class ClientDetailView(ConversationViewBase): request=request, ) return Response({"client": client_detail(organization.id, contact_id)}) + + +class ClientMergeView(ConversationViewBase): + """Объединение контактов и обратное разъединение (ADR-HUB-0006). + + Доступно только владельцу, требует причины и полностью аудируется; + предложение объединения на карточке видят и администраторы. + """ + + required_capabilities = {"POST": "customers.manage", "DELETE": "customers.manage"} + + 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 None + + def post(self, request: Request, contact_id: int) -> Response: + denied = self._owner_only(request) + if denied is not None: + return denied + organization = self._org(request) + try: + merge_contacts( + organization=organization, + target_id=contact_id, + source_id=int(request.data.get("sourceId") or 0), + reason=str(request.data.get("reason", "")), + actor=request.user, + request=request, + ) + except ValidationError as error: + return Response({"detail": "; ".join(error.messages)}, status=400) + return Response({"client": client_detail(organization.id, contact_id)}) + + def delete(self, request: Request, contact_id: int) -> Response: + denied = self._owner_only(request) + if denied is not None: + return denied + organization = self._org(request) + try: + revert_merge( + organization=organization, + merge_id=int(request.data.get("mergeId") or 0), + reason=str(request.data.get("reason", "")), + actor=request.user, + request=request, + ) + except ValidationError as error: + return Response({"detail": "; ".join(error.messages)}, status=400) + return Response({"client": client_detail(organization.id, contact_id)}) diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index b606ccd..3aea172 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -150,6 +150,7 @@ def conversation_payload( payload = { "id": conversation.id, "channel": { + "id": channel.id, "code": channel.code, "name": channel.name, "product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None, diff --git a/apps/backend/chatballs/conversations/test_contacts_merge.py b/apps/backend/chatballs/conversations/test_contacts_merge.py new file mode 100644 index 0000000..07d0d51 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_contacts_merge.py @@ -0,0 +1,203 @@ +"""Объединение и разъединение контактов (ADR-HUB-0006).""" + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from chatballs.channels.models import Channel +from chatballs.conversations.contacts_merge import merge_contacts, revert_merge +from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation +from chatballs.identity.models import ( + AuditEvent, + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.integrations.models import Integration +from chatballs.testing import TenantAPIClient as APIClient + + +class ContactsMergeTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Ателье", slug="atelie") + self.owner = HumanUser.objects.create(email="owner@atelie.test", full_name="Елена Кузнецова") + self.channel = Channel.objects.create(organization=self.organization, code="consultant", name="Консультант") + self.connection = Integration.objects.create( + organization=self.organization, kind="MESSENGER", provider="TELEGRAM", name="Бот", channel=self.channel + ) + self.target = Contact.objects.create(organization=self.organization, name="Мария Соколова", phone="+79162041187") + self.source = Contact.objects.create(organization=self.organization, name="Гость", city="Москва") + self.identity = ConnectionIdentity.objects.create( + contact=self.source, connection=self.connection, external_user_id="482913001", display_name="Гость" + ) + self.conversation = Conversation.objects.create( + organization=self.organization, channel=self.channel, connection=self.connection, contact=self.source + ) + + def test_merge_moves_identities_and_dialogs_and_is_audited(self) -> None: + merge = merge_contacts( + organization=self.organization, + target_id=self.target.id, + source_id=self.source.id, + reason="Один и тот же человек, совпал телефон", + actor=self.owner, + ) + + self.identity.refresh_from_db() + self.conversation.refresh_from_db() + self.source.refresh_from_db() + self.target.refresh_from_db() + self.assertEqual(self.identity.contact_id, self.target.id) + self.assertEqual(self.conversation.contact_id, self.target.id) + self.assertEqual(self.source.merged_into_id, self.target.id) + # Пустое поле целевого контакта дозаполнено из исходного. + self.assertEqual(self.target.city, "Москва") + self.assertEqual(merge.filled_fields, ["city"]) + self.assertTrue( + AuditEvent.objects.filter(organization=self.organization, action="contacts.merged").exists() + ) + + def test_merge_requires_reason(self) -> None: + with self.assertRaises(ValidationError): + merge_contacts( + organization=self.organization, + target_id=self.target.id, + source_id=self.source.id, + reason=" ", + actor=self.owner, + ) + self.identity.refresh_from_db() + self.assertEqual(self.identity.contact_id, self.source.id) + + def test_merge_rejects_already_merged_contact(self) -> None: + merge_contacts( + organization=self.organization, + target_id=self.target.id, + source_id=self.source.id, + reason="Совпал телефон", + actor=self.owner, + ) + with self.assertRaises(ValidationError): + merge_contacts( + organization=self.organization, + target_id=self.target.id, + source_id=self.source.id, + reason="Ещё раз", + actor=self.owner, + ) + + def test_revert_returns_everything_back(self) -> None: + merge = merge_contacts( + organization=self.organization, + target_id=self.target.id, + source_id=self.source.id, + reason="Совпал телефон", + actor=self.owner, + ) + + revert_merge( + organization=self.organization, + merge_id=merge.id, + reason="Разные люди, ошиблись", + actor=self.owner, + ) + + self.identity.refresh_from_db() + self.conversation.refresh_from_db() + self.source.refresh_from_db() + self.target.refresh_from_db() + merge.refresh_from_db() + self.assertEqual(self.identity.contact_id, self.source.id) + self.assertEqual(self.conversation.contact_id, self.source.id) + self.assertIsNone(self.source.merged_into_id) + self.assertEqual(self.target.city, "") + self.assertIsNotNone(merge.reverted_at) + self.assertTrue( + AuditEvent.objects.filter(organization=self.organization, action="contacts.unmerged").exists() + ) + + def test_revert_twice_is_rejected(self) -> None: + merge = merge_contacts( + organization=self.organization, + target_id=self.target.id, + source_id=self.source.id, + reason="Совпал телефон", + actor=self.owner, + ) + revert_merge(organization=self.organization, merge_id=merge.id, reason="Ошиблись", actor=self.owner) + with self.assertRaises(ValidationError): + revert_merge(organization=self.organization, merge_id=merge.id, reason="Ещё раз", actor=self.owner) + + def test_contact_merged_away_disappears_from_the_list(self) -> None: + from chatballs.conversations.clients import clients_overview + + merge_contacts( + organization=self.organization, + target_id=self.target.id, + source_id=self.source.id, + reason="Совпал телефон", + actor=self.owner, + ) + rows = clients_overview(self.organization.id) + self.assertEqual([row["id"] for row in rows], [self.target.id]) + + +class ContactsMergeApiTests(TestCase): + """Объединять и разъединять может только владелец (ADR-HUB-0006).""" + + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Ателье", slug="atelie-api") + self.owner = self._member("owner@atelie.test", EmployeeRole.OWNER) + self.admin = self._member("admin@atelie.test", EmployeeRole.ADMIN) + self.channel = Channel.objects.create(organization=self.organization, code="line", name="Линия") + self.target = Contact.objects.create(organization=self.organization, name="Мария", phone="+79162041187") + self.source = Contact.objects.create(organization=self.organization, name="Гость") + Conversation.objects.create(organization=self.organization, channel=self.channel, contact=self.target) + Conversation.objects.create(organization=self.organization, channel=self.channel, contact=self.source) + self.owner_client = APIClient() + self.owner_client.force_authenticate(self.owner.user) + self.admin_client = APIClient() + self.admin_client.force_authenticate(self.admin.user) + + def _member(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _merge_url(self) -> str: + return f"/api/v1/conversations/clients/{self.target.id}/merge/" + + def test_owner_merges_and_unmerges(self) -> None: + merged = self.owner_client.post( + self._merge_url(), + {"sourceId": self.source.id, "reason": "Один человек, совпал телефон"}, + format="json", + ) + self.assertEqual(merged.status_code, 200) + self.assertEqual(len(merged.json()["client"]["merges"]), 1) + self.assertEqual(merged.json()["client"]["totalDialogs"], 2) + + merge_id = merged.json()["client"]["merges"][0]["id"] + reverted = self.owner_client.delete( + self._merge_url(), {"mergeId": merge_id, "reason": "Разные люди"}, format="json" + ) + self.assertEqual(reverted.status_code, 200) + self.assertEqual(reverted.json()["client"]["merges"], []) + self.assertEqual(reverted.json()["client"]["totalDialogs"], 1) + + def test_admin_cannot_merge(self) -> None: + response = self.admin_client.post( + self._merge_url(), + {"sourceId": self.source.id, "reason": "Один человек, совпал телефон"}, + format="json", + ) + self.assertEqual(response.status_code, 403) + self.source.refresh_from_db() + self.assertIsNone(self.source.merged_into_id) + + def test_reason_is_required_by_the_api(self) -> None: + response = self.owner_client.post( + self._merge_url(), {"sourceId": self.source.id, "reason": ""}, format="json" + ) + self.assertEqual(response.status_code, 400) diff --git a/apps/backend/chatballs/conversations/urls.py b/apps/backend/chatballs/conversations/urls.py index 4eee032..bccc4ac 100644 --- a/apps/backend/chatballs/conversations/urls.py +++ b/apps/backend/chatballs/conversations/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"), path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"), path("clients//", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"), + path("clients//merge/", reporting_views.ClientMergeView.as_view(), name="conversation-client-merge"), path("/", views.ConversationDetailView.as_view(), name="conversation-detail"), path("/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"), path("/release/", views.ConversationReleaseView.as_view(), name="conversation-release"), diff --git a/apps/backend/chatballs/identity/administration_payloads.py b/apps/backend/chatballs/identity/administration_payloads.py index 86db159..b9c762c 100644 --- a/apps/backend/chatballs/identity/administration_payloads.py +++ b/apps/backend/chatballs/identity/administration_payloads.py @@ -34,6 +34,8 @@ AUDIT_ACTION_LABELS = { "ai.knowledge_created": "Добавлено знание", "ai.knowledge_updated": "Изменено знание", "ai.knowledge_deleted": "Удалено знание", + "contacts.merged": "Объединение контактов", + "contacts.unmerged": "Разъединение контактов", } AUDIT_RESULT_LABELS = { @@ -43,11 +45,36 @@ AUDIT_RESULT_LABELS = { } +ORGANIZATION_CHANGE_ACTIONS = ( + "administration.organization_updated", + "administration.logo_updated", + "administration.logo_deleted", +) + + +def organization_updated_at(organization: Organization) -> str | None: + """Когда настройки организации сохраняли в последний раз (кадр N1: + «Сохранено 2 сен, 14:12»). Берём из журнала аудита — отдельного поля + в модели нет.""" + event = ( + AuditEvent.objects.filter( + organization=organization, + action__in=ORGANIZATION_CHANGE_ACTIONS, + result="SUCCESS", + ) + .order_by("-created_at") + .values_list("created_at", flat=True) + .first() + ) + return event.isoformat() if event else None + + def organization_settings_payload(organization: Organization) -> dict[str, object]: return { "name": organization.name, "timezone": organization.timezone, "currency": organization.currency, + "updatedAt": organization_updated_at(organization), "logoUrl": ( reverse( "organization-logo", diff --git a/apps/backend/chatballs/identity/auth/__init__.py b/apps/backend/chatballs/identity/auth/__init__.py index 9ffb773..1b5d296 100644 --- a/apps/backend/chatballs/identity/auth/__init__.py +++ b/apps/backend/chatballs/identity/auth/__init__.py @@ -10,6 +10,7 @@ from chatballs.identity.auth.profile import ( ChangeTemporaryPasswordView, ProfilePasswordView, ProfileRevokeOtherSessionsView, + ProfileSessionsView, ProfileTotpDisableView, ProfileTotpStartView, ProfileUpdateView, @@ -29,6 +30,7 @@ __all__ = [ "ProfileTotpStartView", "ProfileTotpDisableView", "ProfileRevokeOtherSessionsView", + "ProfileSessionsView", "ChangeTemporaryPasswordView", "TotpSetupView", "TotpConfirmView", diff --git a/apps/backend/chatballs/identity/auth/common.py b/apps/backend/chatballs/identity/auth/common.py index c0d0606..91906a4 100644 --- a/apps/backend/chatballs/identity/auth/common.py +++ b/apps/backend/chatballs/identity/auth/common.py @@ -46,6 +46,8 @@ def _user_payload(user: HumanUser) -> dict[str, object]: "role": membership.role, "positionTitle": membership.position_title, "totpRequired": membership.totp_required, + # «в организации с …» в шапке «Профиля» (кадр P1). + "joinedAt": membership.created_at.isoformat(), } membership_payload.update(get_effective_access(membership)) memberships.append(membership_payload) @@ -56,6 +58,7 @@ def _user_payload(user: HumanUser) -> dict[str, object]: "fullName": user.full_name, "mustChangePassword": user.must_change_password, "totpEnabled": user.totp_enabled, + "totpLastUsedAt": user.totp_last_used_at.isoformat() if user.totp_last_used_at else None, "deliveryMode": settings.CHATBALLS_DELIVERY_MODE, "uiTheme": user.ui_theme, "uiAccent": user.ui_accent, diff --git a/apps/backend/chatballs/identity/auth/password_reset.py b/apps/backend/chatballs/identity/auth/password_reset.py index 40bdbb3..6e9d631 100644 --- a/apps/backend/chatballs/identity/auth/password_reset.py +++ b/apps/backend/chatballs/identity/auth/password_reset.py @@ -1,6 +1,7 @@ from django.contrib.auth.password_validation import validate_password from django.contrib.auth.tokens import default_token_generator from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.encoding import force_str from django.utils.http import urlsafe_base64_decode @@ -95,7 +96,8 @@ class PasswordResetConfirmView(APIView): user.set_password(new_password) user.must_change_password = False - user.save(update_fields=["password", "must_change_password"]) + user.password_changed_at = timezone.now() + user.save(update_fields=["password", "must_change_password", "password_changed_at"]) record_audit_event( action="identity.password_reset_completed", actor=user, diff --git a/apps/backend/chatballs/identity/auth/profile.py b/apps/backend/chatballs/identity/auth/profile.py index 5e0712a..9436fdb 100644 --- a/apps/backend/chatballs/identity/auth/profile.py +++ b/apps/backend/chatballs/identity/auth/profile.py @@ -2,6 +2,7 @@ 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 rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request @@ -10,6 +11,7 @@ from rest_framework.views import APIView from chatballs.identity.audit import record_audit_event from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload +from chatballs.identity.sessions import list_user_sessions from chatballs.tenancy.ingress import user_requires_totp from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar from chatballs.identity.models import HumanUser @@ -99,7 +101,8 @@ class ProfilePasswordView(APIView): return Response({"detail": " ".join(error.messages)}, status=400) request.user.set_password(new_password) - request.user.save(update_fields=["password"]) + request.user.password_changed_at = timezone.now() + request.user.save(update_fields=["password", "password_changed_at"]) login(request, request.user) revoked = _revoke_other_user_sessions(request) record_audit_event( @@ -149,6 +152,16 @@ class ProfileTotpDisableView(APIView): return Response({"authenticated": True, "user": _user_payload(request.user), "revoked": revoked}) +class ProfileSessionsView(APIView): + """Список активных сессий учётной записи (кадр P1).""" + + permission_classes = [IsAuthenticated] + + def get(self, request: Request) -> Response: + items = list_user_sessions(request.user.id, request.session.session_key) + return Response({"items": items}) + + class ProfileRevokeOtherSessionsView(APIView): permission_classes = [IsAuthenticated] @@ -179,7 +192,8 @@ class ChangeTemporaryPasswordView(APIView): request.user.set_password(new_password) request.user.must_change_password = False - request.user.save(update_fields=["password", "must_change_password"]) + request.user.password_changed_at = timezone.now() + request.user.save(update_fields=["password", "must_change_password", "password_changed_at"]) login(request, request.user) record_audit_event( action="identity.temporary_password_changed", diff --git a/apps/backend/chatballs/identity/auth/sessions.py b/apps/backend/chatballs/identity/auth/sessions.py index f6bd9a6..3f6db4a 100644 --- a/apps/backend/chatballs/identity/auth/sessions.py +++ b/apps/backend/chatballs/identity/auth/sessions.py @@ -11,6 +11,7 @@ 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 from chatballs.identity.models import AuditResult +from chatballs.identity.sessions import remember_device @method_decorator(ensure_csrf_cookie, name="dispatch") @@ -56,6 +57,7 @@ class LoginView(APIView): ) login(request, user) + remember_device(request) record_audit_event( action="identity.login_succeeded", actor=user, diff --git a/apps/backend/chatballs/identity/auth/totp.py b/apps/backend/chatballs/identity/auth/totp.py index f908242..757f32c 100644 --- a/apps/backend/chatballs/identity/auth/totp.py +++ b/apps/backend/chatballs/identity/auth/totp.py @@ -1,6 +1,7 @@ from urllib.parse import quote from django.contrib.auth import login +from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_protect from rest_framework.permissions import AllowAny, IsAuthenticated @@ -19,6 +20,7 @@ from chatballs.identity.auth.totp_utils import ( _verify_totp, ) from chatballs.identity.models import AuditResult, HumanUser +from chatballs.identity.sessions import remember_device class TotpSetupView(APIView): @@ -60,7 +62,8 @@ class TotpConfirmView(APIView): return Response({"detail": "Invalid TOTP code"}, status=400) request.user.totp_enabled = True - request.user.save(update_fields=["totp_enabled"]) + request.user.totp_last_used_at = timezone.now() + request.user.save(update_fields=["totp_enabled", "totp_last_used_at"]) record_audit_event( action="identity.totp_enabled", actor=request.user, @@ -103,7 +106,11 @@ class TotpVerifyView(APIView): return Response({"detail": "Invalid TOTP code"}, status=400) request.session.pop(TOTP_SESSION_KEY, None) + # Отметка «последний код принят …» в карточке 2FA (кадр P1). + user.totp_last_used_at = timezone.now() + user.save(update_fields=["totp_last_used_at"]) login(request, user) + remember_device(request) record_audit_event( action="identity.login_succeeded", actor=user, diff --git a/apps/backend/chatballs/identity/auth_urls.py b/apps/backend/chatballs/identity/auth_urls.py index eb9ff63..1d6fc9b 100644 --- a/apps/backend/chatballs/identity/auth_urls.py +++ b/apps/backend/chatballs/identity/auth_urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path("profile/password/", auth.ProfilePasswordView.as_view(), name="auth-profile-password"), path("profile/totp/start/", auth.ProfileTotpStartView.as_view(), name="auth-profile-totp-start"), path("profile/totp/disable/", auth.ProfileTotpDisableView.as_view(), name="auth-profile-totp-disable"), + path("profile/sessions/", auth.ProfileSessionsView.as_view(), name="auth-profile-sessions"), path("profile/sessions/revoke-other/", auth.ProfileRevokeOtherSessionsView.as_view(), name="auth-profile-revoke-other-sessions"), path("change-temporary-password/", auth.ChangeTemporaryPasswordView.as_view(), name="auth-change-temp-password"), path("totp/setup/", auth.TotpSetupView.as_view(), name="auth-totp-setup"), diff --git a/apps/backend/chatballs/identity/demo_seed/data/conversations.json b/apps/backend/chatballs/identity/demo_seed/data/conversations.json index a3638f7..9927854 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/conversations.json +++ b/apps/backend/chatballs/identity/demo_seed/data/conversations.json @@ -10,6 +10,13 @@ "description": "Заказ 4471 — комплект штор. Постоянный клиент с августа, предпочитает Telegram.", "createdDaysAgo": 34 }, + { + "key": "maria-duplicate", + "name": "Мария С.", + "phone": "+7 917 254-18-40", + "description": "Дубль: обратилась с сайта, пока не узнали по телефону.", + "createdDaysAgo": 12 + }, { "key": "irina", "name": "Ирина Левина", @@ -936,5 +943,13 @@ } ] } + ], + "contactMerges": [ + { + "target": "maria", + "source": "maria-duplicate", + "reason": "Один и тот же человек: совпал телефон, подтвердили в диалоге", + "actor": "elena" + } ] } diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py index 32f6517..58cf678 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py @@ -66,6 +66,10 @@ def load(context: TenantContext, refs: DemoRefs) -> None: organization=organization, title=item["title"], defaults={"text": item["text"]} ) + # Объединение контактов (ADR-HUB-0006): демо показывает и историю слияний. + for item in data.get("contactMerges", []): + _merge_contacts(context, refs, item) + # Диалоги с previousConversation ссылаются на более ранние — создаём в два прохода. pending = list(data["conversations"]) created_keys: set[str] = set() @@ -85,6 +89,23 @@ def load(context: TenantContext, refs: DemoRefs) -> None: ) +def _merge_contacts(context: TenantContext, refs: DemoRefs, item: dict) -> None: + from chatballs.conversations.contacts_merge import merge_contacts + from chatballs.conversations.models import ContactMerge + + target = refs.contacts[item["target"]] + source = refs.contacts[item["source"]] + if ContactMerge.objects.filter(target=target, source=source).exists(): + return + merge_contacts( + organization=refs.organization, + target_id=target.id, + source_id=source.id, + reason=item["reason"], + actor=refs.users.get(item.get("actor")), + ) + + def _ensure_identity(refs: DemoRefs, item: dict) -> None: connection = refs.integrations[item["connection"]] contact = refs.contacts[item["contact"]] diff --git a/apps/backend/chatballs/identity/demo_seed/service.py b/apps/backend/chatballs/identity/demo_seed/service.py index 17c7acc..dae7404 100644 --- a/apps/backend/chatballs/identity/demo_seed/service.py +++ b/apps/backend/chatballs/identity/demo_seed/service.py @@ -16,6 +16,7 @@ from django.utils import timezone from chatballs.events.services import DomainEvent, enqueue_event from chatballs.identity.audit import record_audit_event +from chatballs.identity.avatars import user_avatar_url_in from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus from chatballs.identity.demo_seed.orchestrator import run_demo_seed from chatballs.identity.demo_seed.registry import recording, remove_records @@ -34,7 +35,7 @@ class DemoBusy(ValidationError): """Установка или удаление уже идут.""" -def showcase_accounts() -> list[dict[str, object]]: +def showcase_accounts(organization_id: int) -> list[dict[str, object]]: """Учётки для входа «посмотреть глазами сотрудника»: админ и по одному сотруднику из разных групп. Пароль общий и намеренно публичный — это демо.""" from chatballs.identity.demo_seed import manifest @@ -42,9 +43,13 @@ def showcase_accounts() -> list[dict[str, object]]: data = manifest.load("organization") by_key = {item["key"]: item for item in data["accounts"]} groups = {item["key"]: item["name"] for item in data.get("groups", [])} + keys = data.get("showcaseAccounts", []) + emails = [by_key[key]["email"] for key in keys] + users = {user.email: user for user in HumanUser.objects.filter(email__in=emails)} accounts = [] - for key in data.get("showcaseAccounts", []): + for key in keys: item = by_key[key] + user = users.get(item["email"]) accounts.append( { "fullName": item["fullName"], @@ -53,6 +58,8 @@ def showcase_accounts() -> list[dict[str, object]]: "role": item["role"], "positionTitle": item.get("positionTitle", ""), "groups": [groups[g] for g in item.get("groups", []) if g in groups], + # Фото сотрудника — кадр N7 показывает его в строке учётки. + "avatarUrl": user_avatar_url_in(user, organization_id) if user else None, } ) return accounts @@ -76,7 +83,7 @@ def demo_status(organization: Organization) -> dict[str, object]: "error": dataset.error, "startedAt": dataset.started_at.isoformat(), "finishedAt": dataset.finished_at.isoformat() if dataset.finished_at else None, - "accounts": showcase_accounts() if installed else [], + "accounts": showcase_accounts(organization.id) if installed else [], } diff --git a/apps/backend/chatballs/identity/employee_password.py b/apps/backend/chatballs/identity/employee_password.py new file mode 100644 index 0000000..deed1be --- /dev/null +++ b/apps/backend/chatballs/identity/employee_password.py @@ -0,0 +1,95 @@ +"""Пароль первичного доступа сотрудника (дизайн-базлайн v2, кадры E5–E8). + +Владелец и администратор задают, как сотрудник получит первый пароль: + +* ``mail`` — письмо со ссылкой первого входа на рабочую почту (поведение по + умолчанию, как было раньше); +* ``show`` — сервер генерирует пароль, возвращает его в ответе ровно один раз и + сохраняет только хеш. Это нужно, когда почта организации ещё не работает. + +В обоих случаях сотруднику ставится ``must_change_password``: свой пароль он +задаёт при первом входе. Выдача и сброс пишутся в журнал действий. +""" + +from __future__ import annotations + +import secrets + +from django.utils import timezone + +from chatballs.identity.audit import record_audit_event +from chatballs.identity.models import HumanUser, OrganizationMembership +from chatballs.identity.sessions import revoke_user_sessions + +PASSWORD_MODES = ("mail", "show") + +# Без похожих друг на друга символов: пароль диктуют голосом и переписывают. +_ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" +_GROUPS = (3, 4, 4) + + +def clean_password_mode(value: object) -> str | None: + """Режим выдачи пароля из тела запроса; None — значение не поддерживается.""" + mode = str(value or "mail") + return mode if mode in PASSWORD_MODES else None + + +def generate_initial_password() -> str: + """Пароль вида «kv7-Rt94-Xmz2»: группы через дефис читаются вслух.""" + return "-".join( + "".join(secrets.choice(_ALPHABET) for _ in range(size)) for size in _GROUPS + ) + + +def issue_initial_password(user: HumanUser) -> str: + """Поставить сотруднику сгенерированный пароль и вернуть его вызывающему. + + В базу уходит только хеш: открытый пароль живёт лишь в этом ответе. + """ + password = generate_initial_password() + user.set_password(password) + user.must_change_password = True + user.password_changed_at = timezone.now() + user.save(update_fields=["password", "must_change_password", "password_changed_at"]) + return password + + +def reset_employee_password( + *, profile: OrganizationMembership, mode: str, actor, request=None +) -> str | None: + """Сброс пароля сотрудника. Возвращает пароль только в режиме «показать». + + Активные сессии завершаются в обоих режимах: старый пароль больше не + действует, и вход по нему из уже открытых окон продолжаться не должен. + """ + from chatballs.events.services import DomainEvent, enqueue_event + from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED + + user = profile.user + password = issue_initial_password(user) if mode == "show" else None + if mode == "mail": + user.must_change_password = True + user.set_unusable_password() + user.password_changed_at = timezone.now() + user.save(update_fields=["password", "must_change_password", "password_changed_at"]) + revoked = revoke_user_sessions(user.id) + record_audit_event( + action="identity.employee_password_reset", + actor=getattr(actor, "user", actor), + organization=profile.organization, + object_type="HumanUser", + object_id=str(user.id), + payload={"mode": mode, "sessionsRevoked": revoked}, + request=request, + ) + if mode == "mail" and request is not None: + enqueue_event( + DomainEvent( + aggregate_type="HumanUser", + aggregate_id=str(user.id), + event_type=INITIAL_ACCESS_REQUESTED, + payload={"userId": user.id}, + tenant_context=request.tenant_context, + ) + ) + return password diff --git a/apps/backend/chatballs/identity/employee_security_views.py b/apps/backend/chatballs/identity/employee_security_views.py index 96c7754..778673c 100644 --- a/apps/backend/chatballs/identity/employee_security_views.py +++ b/apps/backend/chatballs/identity/employee_security_views.py @@ -5,30 +5,77 @@ from rest_framework.response import Response from rest_framework.views import APIView 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 from chatballs.identity.employee_validation import deny_employee_action from chatballs.identity.governance import EmployeeAction, can_manage_employee +from chatballs.identity.sessions import revoke_user_sessions class EmployeeResetPasswordView(APIView): + """Сброс пароля сотрудника (дизайн-базлайн v2, кадр E8). + + ``mode=show`` возвращает сгенерированный пароль ровно один раз — открытым он + нигде не хранится; ``mode=mail`` отправляет письмо со ссылкой первого входа. + """ + permission_classes = [IsAuthenticated] @transaction.atomic def post(self, request: Request, user_id: int) -> Response: + actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: return Response({"detail": "Employee not found"}, status=404) - return deny_employee_action(request, profile, EmployeeAction.RESET_PASSWORD) + if not can_manage_employee(actor, profile, EmployeeAction.RESET_PASSWORD): + return deny_employee_action(request, profile, EmployeeAction.RESET_PASSWORD) + mode = clean_password_mode(request.data.get("mode")) + if mode is None: + return Response({"detail": "Unknown password mode"}, status=400) + # Пароль живёт на пользователе, а не на членстве: сбрасывать его из одной + # организации, когда человек работает и в другой, нельзя. + if profile.user.memberships.count() > 1: + return Response( + { + "detail": ( + "Сотрудник состоит в нескольких организациях — " + "пароль сбрасывает он сам через восстановление доступа" + ) + }, + status=409, + ) + password = reset_employee_password( + profile=profile, mode=mode, actor=actor, request=request + ) + profile.refresh_from_db() + return Response( + {"employee": employee_payload(profile, actor), "password": password} + ) class EmployeeRevokeSessionsView(APIView): + """Завершение всех сессий сотрудника (кадры E2/E3).""" + permission_classes = [IsAuthenticated] def post(self, request: Request, user_id: int) -> Response: + actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: return Response({"detail": "Employee not found"}, status=404) - return deny_employee_action(request, profile, EmployeeAction.TERMINATE_SESSIONS) + if not can_manage_employee(actor, profile, EmployeeAction.TERMINATE_SESSIONS): + return deny_employee_action(request, profile, EmployeeAction.TERMINATE_SESSIONS) + revoked = revoke_user_sessions(profile.user_id) + record_audit_event( + action="identity.employee_sessions_terminated", + actor=request.user, + organization=profile.organization, + object_type="HumanUser", + object_id=str(profile.user_id), + payload={"sessionsRevoked": revoked}, + request=request, + ) + return Response({"employee": employee_payload(profile, actor), "revoked": revoked}) class EmployeeBlockView(APIView): diff --git a/apps/backend/chatballs/identity/employee_support.py b/apps/backend/chatballs/identity/employee_support.py index 5a5ce67..baf4ae8 100644 --- a/apps/backend/chatballs/identity/employee_support.py +++ b/apps/backend/chatballs/identity/employee_support.py @@ -29,6 +29,12 @@ def employee_payload( "isActive": profile.user.is_active, "isBlocked": profile.is_blocked, "mustChangePassword": profile.user.must_change_password, + # «Последняя смена» пароля в карточке сотрудника (кадр E3). + "passwordChangedAt": ( + profile.user.password_changed_at.isoformat() + if profile.user.password_changed_at + else None + ), "totpRequired": profile.totp_required, "totpEnabled": profile.user.totp_enabled, } diff --git a/apps/backend/chatballs/identity/employee_views.py b/apps/backend/chatballs/identity/employee_views.py index 7618c37..96dae08 100644 --- a/apps/backend/chatballs/identity/employee_views.py +++ b/apps/backend/chatballs/identity/employee_views.py @@ -14,6 +14,7 @@ from chatballs.identity.employee_validation import ( deny_employee_action, resolve_groups, ) +from chatballs.identity.employee_password import clean_password_mode, issue_initial_password from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee from chatballs.identity.group_models import EmployeeGroupMember @@ -90,6 +91,8 @@ class EmployeeCreateView(APIView): full_name = str(body.get("fullName", "")).strip() phone = str(body.get("phone", "")).strip() provided_password = str(body.get("temporaryPassword", "")) + # Кадры E5/E6: пароль первичного доступа письмом либо показать один раз. + password_mode = clean_password_mode(body.get("passwordMode")) position_title, position_error = clean_position_title(body.get("positionTitle")) requested_role = str(body.get("role", EmployeeRole.EMPLOYEE)) @@ -107,6 +110,8 @@ class EmployeeCreateView(APIView): return Response({"detail": position_error}, status=400) if provided_password: return Response({"detail": "Temporary passwords are not supported"}, status=400) + if password_mode is None: + return Response({"detail": "Unknown password mode"}, status=400) if HumanUser.objects.filter(email=email).exists(): return Response({"detail": "Email is already used"}, status=400) @@ -131,25 +136,30 @@ class EmployeeCreateView(APIView): ) if groups: _set_groups(profile, groups) + password = issue_initial_password(user) if password_mode == "show" else None record_audit_event( action="identity.employee_created", actor=request.user, organization=actor.organization, object_type="HumanUser", object_id=str(user.id), - payload={"role": requested_role}, + payload={"role": requested_role, "passwordMode": password_mode}, request=request, ) - enqueue_event( - DomainEvent( - aggregate_type="HumanUser", - aggregate_id=str(user.id), - event_type=INITIAL_ACCESS_REQUESTED, - payload={"userId": user.id}, - tenant_context=request.tenant_context, + if password_mode == "mail": + enqueue_event( + DomainEvent( + aggregate_type="HumanUser", + aggregate_id=str(user.id), + event_type=INITIAL_ACCESS_REQUESTED, + payload={"userId": user.id}, + tenant_context=request.tenant_context, + ) ) + return Response( + {"employee": employee_payload(profile, actor), "password": password}, + status=201, ) - return Response({"employee": employee_payload(profile, actor)}, status=201) class EmployeeDetailView(APIView): diff --git a/apps/backend/chatballs/identity/middleware.py b/apps/backend/chatballs/identity/middleware.py new file mode 100644 index 0000000..adba8f7 --- /dev/null +++ b/apps/backend/chatballs/identity/middleware.py @@ -0,0 +1,21 @@ +"""Отметка активности сессии. + +Карточка «Активные сессии» в «Профиле» (дизайн-базлайн v2, кадр P1) показывает, +когда сессия была активна. Отдельной таблицы нет: отметка живёт в самой сессии +и обновляется не чаще раза в минуту, чтобы не переписывать её на каждый запрос. +""" + +from __future__ import annotations + +from chatballs.identity.sessions import touch_session + + +class SessionActivityMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = getattr(request, "user", None) + if user is not None and user.is_authenticated and hasattr(request, "session"): + touch_session(request) + return self.get_response(request) diff --git a/apps/backend/chatballs/identity/migrations/0025_humanuser_totp_last_used_at.py b/apps/backend/chatballs/identity/migrations/0025_humanuser_totp_last_used_at.py new file mode 100644 index 0000000..9910893 --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0025_humanuser_totp_last_used_at.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("identity", "0024_employeegroup_color"), + ] + + operations = [ + migrations.AddField( + model_name="humanuser", + name="totp_last_used_at", + field=models.DateTimeField(blank=True, db_default=None, null=True), + ), + ] diff --git a/apps/backend/chatballs/identity/migrations/0026_humanuser_password_changed_at.py b/apps/backend/chatballs/identity/migrations/0026_humanuser_password_changed_at.py new file mode 100644 index 0000000..f5cf096 --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0026_humanuser_password_changed_at.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("identity", "0025_humanuser_totp_last_used_at"), + ] + + operations = [ + migrations.AddField( + model_name="humanuser", + name="password_changed_at", + field=models.DateTimeField(blank=True, db_default=None, null=True), + ), + ] diff --git a/apps/backend/chatballs/identity/models.py b/apps/backend/chatballs/identity/models.py index 19704f9..a3565da 100644 --- a/apps/backend/chatballs/identity/models.py +++ b/apps/backend/chatballs/identity/models.py @@ -63,8 +63,13 @@ class HumanUser(AbstractUser): email = models.EmailField(unique=True) full_name = models.CharField(max_length=255, blank=True) must_change_password = models.BooleanField(default=False) + # «Последняя смена пароля» в карточке сотрудника (кадр E3). + password_changed_at = models.DateTimeField(null=True, blank=True, db_default=None) totp_enabled = models.BooleanField(default=False) totp_secret = EncryptedCharField(max_length=255, blank=True) + # Когда последний раз принимался код аутентификатора — подпись в карточке + # «Двухфакторная аутентификация» (дизайн-базлайн v2, кадр P1). + totp_last_used_at = models.DateTimeField(null=True, blank=True, db_default=None) # Внешний вид — глобальная настройка пользователя (не membership): # тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff. ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM) diff --git a/apps/backend/chatballs/identity/sessions.py b/apps/backend/chatballs/identity/sessions.py index c5508e9..8fa4803 100644 --- a/apps/backend/chatballs/identity/sessions.py +++ b/apps/backend/chatballs/identity/sessions.py @@ -1,12 +1,133 @@ +"""Серверные сессии пользователя: счётчик, список для «Профиля» и отзыв. + +Карточка «Активные сессии» (дизайн-базлайн v2, кадр P1) показывает устройство, +адрес и когда сессия была активна. Отдельной таблицы нет: браузер и адрес +пишутся в саму сессию при входе, отметка активности обновляется middleware +не чаще раза в минуту. +""" + +from __future__ import annotations + +import re + from django.contrib.sessions.models import Session +from django.utils import timezone + +SESSION_AGENT_KEY = "device_agent" +SESSION_IP_KEY = "device_ip" +SESSION_STARTED_KEY = "device_started" +SESSION_SEEN_KEY = "device_seen" +# Чаще раза в минуту отметку не обновляем: иначе запись сессии на каждый запрос. +SEEN_THROTTLE_SECONDS = 60 + +_BROWSERS = ( + ("YaBrowser", "Яндекс.Браузер"), + ("Edg/", "Edge"), + ("OPR/", "Opera"), + ("Firefox", "Firefox"), + ("Chrome", "Chrome"), + ("Safari", "Safari"), +) +_PLATFORMS = ( + ("Android", "Android"), + ("iPhone", "iPhone"), + ("iPad", "iPad"), + ("Macintosh", "macOS"), + ("Windows", "Windows"), + ("Linux", "Linux"), +) + + +def describe_agent(user_agent: str) -> str: + """«Chrome · macOS» из строки User-Agent; неизвестное — «Браузер».""" + browser = next((label for token, label in _BROWSERS if token in user_agent), "") + platform = next((label for token, label in _PLATFORMS if token in user_agent), "") + if browser and platform: + return f"{browser} · {platform}" + return browser or platform or "Браузер" + + +def device_kind(user_agent: str) -> str: + """Какой значок ставить в строке сессии: телефон, ноутбук или монитор.""" + if any(token in user_agent for token in ("iPhone", "Android", "iPad")): + return "phone" + if "Macintosh" in user_agent: + return "laptop" + return "monitor" + + +def mask_ip(value: str) -> str: + """Адрес показываем частично: «91.108.•.•» (кадр P1).""" + if not value: + return "" + if re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", value): + first, second, *_ = value.split(".") + return f"{first}.{second}.•.•" + head = value.split(":")[0] + return f"{head}:•" if head else "" + + +def client_ip(request) -> str: + forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.META.get("REMOTE_ADDR", "") + + +def remember_device(request) -> None: + """Запомнить браузер и адрес в сессии — вызывается сразу после login().""" + now = timezone.now().isoformat() + request.session[SESSION_AGENT_KEY] = request.META.get("HTTP_USER_AGENT", "")[:400] + request.session[SESSION_IP_KEY] = client_ip(request) + request.session[SESSION_STARTED_KEY] = now + request.session[SESSION_SEEN_KEY] = now + + +def touch_session(request) -> None: + """Обновить отметку активности, но не чаще SEEN_THROTTLE_SECONDS.""" + previous = request.session.get(SESSION_SEEN_KEY) + now = timezone.now() + if previous: + try: + elapsed = (now - timezone.datetime.fromisoformat(previous)).total_seconds() + except (TypeError, ValueError): + elapsed = SEEN_THROTTLE_SECONDS + if elapsed < SEEN_THROTTLE_SECONDS: + return + request.session[SESSION_SEEN_KEY] = now.isoformat() + + +def _decoded_sessions(user_id: int): + for session in Session.objects.all(): + data = session.get_decoded() + if str(data.get("_auth_user_id")) == str(user_id): + yield session, data def count_user_sessions(user_id: int) -> int: - return sum( - 1 - for session in Session.objects.all() - if str(session.get_decoded().get("_auth_user_id")) == str(user_id) - ) + return sum(1 for _ in _decoded_sessions(user_id)) + + +def list_user_sessions(user_id: int, current_session_key: str | None = None) -> list[dict[str, object]]: + """Строки карточки «Активные сессии»: устройство, адрес, когда активна.""" + items = [] + for session, data in _decoded_sessions(user_id): + agent = str(data.get(SESSION_AGENT_KEY, "")) + items.append( + { + "id": session.session_key[:12], + "device": describe_agent(agent), + "kind": device_kind(agent), + "address": mask_ip(str(data.get(SESSION_IP_KEY, ""))), + "startedAt": data.get(SESSION_STARTED_KEY), + "lastSeenAt": data.get(SESSION_SEEN_KEY), + "current": session.session_key == current_session_key, + } + ) + # Текущая сессия первой, дальше — по свежести активности. + items.sort(key=lambda item: str(item["lastSeenAt"] or ""), reverse=True) + items.sort(key=lambda item: not item["current"]) + return items def revoke_user_sessions(user_id: int, *, except_session_key: str | None = None) -> int: diff --git a/apps/backend/chatballs/identity/test_administration_api.py b/apps/backend/chatballs/identity/test_administration_api.py index 267a003..6d8104c 100644 --- a/apps/backend/chatballs/identity/test_administration_api.py +++ b/apps/backend/chatballs/identity/test_administration_api.py @@ -61,6 +61,8 @@ class AdministrationApiTests(TestCase): "timezone": "Europe/Moscow", "currency": "RUB", "logoUrl": None, + # Подпись «Сохранено …» у кнопки (кадр N1): пока правок не было — null. + "updatedAt": None, }, ) self.assertIn("Europe/Moscow", response.json()["timezones"]) diff --git a/apps/backend/chatballs/identity/test_employee_password.py b/apps/backend/chatballs/identity/test_employee_password.py new file mode 100644 index 0000000..b984e2d --- /dev/null +++ b/apps/backend/chatballs/identity/test_employee_password.py @@ -0,0 +1,126 @@ +"""Пароль первичного доступа и сессии сотрудника (дизайн-базлайн v2, E5–E8).""" + +import json + +from django.test import TestCase + +from chatballs.identity.employee_password import generate_initial_password +from chatballs.identity.models import ( + AuditEvent, + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.testing import TenantAPIClient as APIClient + + +class EmployeePasswordTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Ателье", slug="atelie-pwd") + self.owner = self._member("owner@atelie.test", EmployeeRole.OWNER) + self.employee = self._member("operator@atelie.test", EmployeeRole.EMPLOYEE) + self.client = APIClient() + self.client.force_authenticate(self.owner.user) + + def _member(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _post(self, path: str, body: dict | None = None): + return self.client.post(path, data=json.dumps(body or {}), content_type="application/json") + + def test_generated_password_is_readable(self) -> None: + """Пароль вида «kv7-Rt94-Xmz2»: три группы, без похожих символов.""" + password = generate_initial_password() + groups = password.split("-") + self.assertEqual([len(part) for part in groups], [3, 4, 4]) + self.assertFalse(set(password) & set("lI0Oo1")) + + def test_reset_show_returns_password_once_and_stores_only_hash(self) -> None: + response = self._post( + f"/api/v1/employees/{self.employee.user_id}/reset-password/", {"mode": "show"} + ) + self.assertEqual(response.status_code, 200) + password = response.json()["password"] + self.assertTrue(password) + + self.employee.user.refresh_from_db() + # В базе только хеш: открытый пароль живёт лишь в этом ответе. + self.assertNotIn(password, self.employee.user.password) + self.assertTrue(self.employee.user.check_password(password)) + self.assertTrue(self.employee.user.must_change_password) + self.assertIsNotNone(self.employee.user.password_changed_at) + self.assertTrue( + AuditEvent.objects.filter( + organization=self.organization, action="identity.employee_password_reset" + ).exists() + ) + + def test_reset_mail_does_not_return_password(self) -> None: + response = self._post( + f"/api/v1/employees/{self.employee.user_id}/reset-password/", {"mode": "mail"} + ) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.json()["password"]) + self.employee.user.refresh_from_db() + self.assertFalse(self.employee.user.has_usable_password()) + + def test_unknown_password_mode_is_rejected(self) -> None: + response = self._post( + f"/api/v1/employees/{self.employee.user_id}/reset-password/", {"mode": "sms"} + ) + self.assertEqual(response.status_code, 400) + + def test_employee_cannot_reset_another_password(self) -> None: + client = APIClient() + client.force_authenticate(self.employee.user) + response = client.post( + f"/api/v1/employees/{self.owner.user_id}/reset-password/", + data=json.dumps({"mode": "show"}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + + def test_sessions_are_terminated_and_audited(self) -> None: + response = self._post(f"/api/v1/employees/{self.employee.user_id}/revoke-sessions/") + self.assertEqual(response.status_code, 200) + self.assertIn("revoked", response.json()) + self.assertTrue( + AuditEvent.objects.filter( + organization=self.organization, action="identity.employee_sessions_terminated" + ).exists() + ) + + def test_create_returns_password_only_in_show_mode(self) -> None: + shown = self._post( + "/api/v1/employees/operators/", + { + "email": "new@atelie.test", + "fullName": "Ольга Титова", + "positionTitle": "Оператор", + "role": EmployeeRole.EMPLOYEE, + "passwordMode": "show", + }, + ) + self.assertEqual(shown.status_code, 201) + password = shown.json()["password"] + self.assertTrue(password) + created = HumanUser.objects.get(email="new@atelie.test") + self.assertTrue(created.check_password(password)) + self.assertTrue(created.must_change_password) + + mailed = self._post( + "/api/v1/employees/operators/", + { + "email": "mail@atelie.test", + "fullName": "Павел Зайцев", + "positionTitle": "Оператор", + "role": EmployeeRole.EMPLOYEE, + "passwordMode": "mail", + }, + ) + self.assertEqual(mailed.status_code, 201) + self.assertIsNone(mailed.json()["password"]) diff --git a/apps/backend/chatballs/identity/test_sessions_meta.py b/apps/backend/chatballs/identity/test_sessions_meta.py new file mode 100644 index 0000000..33f8315 --- /dev/null +++ b/apps/backend/chatballs/identity/test_sessions_meta.py @@ -0,0 +1,30 @@ +"""Разбор устройства и адреса для карточки «Активные сессии» (кадр P1).""" + +from django.test import SimpleTestCase + +from chatballs.identity.sessions import describe_agent, device_kind, mask_ip + + +class SessionMetaTests(SimpleTestCase): + def test_describes_browser_and_platform(self) -> None: + chrome_mac = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36" + ) + safari_iphone = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" + ) + self.assertEqual(describe_agent(chrome_mac), "Chrome · macOS") + self.assertEqual(describe_agent(safari_iphone), "Safari · iPhone") + self.assertEqual(describe_agent(""), "Браузер") + + def test_picks_device_icon(self) -> None: + self.assertEqual(device_kind("iPhone"), "phone") + self.assertEqual(device_kind("Macintosh"), "laptop") + self.assertEqual(device_kind("Windows NT 10.0"), "monitor") + + def test_masks_address(self) -> None: + self.assertEqual(mask_ip("91.108.4.17"), "91.108.•.•") + self.assertEqual(mask_ip("2a00:1450:4010:c07::8a"), "2a00:•") + self.assertEqual(mask_ip(""), "") diff --git a/apps/backend/chatballs/identity/tests.py b/apps/backend/chatballs/identity/tests.py index 1826601..5d100c3 100644 --- a/apps/backend/chatballs/identity/tests.py +++ b/apps/backend/chatballs/identity/tests.py @@ -539,16 +539,41 @@ class EmployeeEndpointTests(TestCase): self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 03") self.assertFalse(operator.totp_enabled) - def test_owner_cannot_reset_operator_global_password(self) -> None: + def test_owner_resets_operator_password(self) -> None: + """Кадр E8: владелец сбрасывает пароль и получает его один раз.""" operator = HumanUser.objects.get(email="staff.member@example.org") password_hash = operator.password - response = self.client.post(f"/api/v1/employees/{operator.id}/reset-password/") + response = self.client.post( + f"/api/v1/employees/{operator.id}/reset-password/", + data=json.dumps({"mode": "show"}), + content_type="application/json", + ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) + operator.refresh_from_db() + self.assertNotEqual(operator.password, password_hash) + self.assertTrue(operator.must_change_password) + self.assertTrue(operator.check_password(response.json()["password"])) + + def test_reset_is_refused_for_multi_organization_user(self) -> None: + """Пароль общий для всех организаций человека — сбрасывать его из одной нельзя.""" + operator = HumanUser.objects.get(email="staff.member@example.org") + other = Organization.objects.create(name="Другая", slug="other-org") + OrganizationMembership.objects.create( + user=operator, organization=other, role=EmployeeRole.EMPLOYEE, position_title="Оператор" + ) + password_hash = operator.password + + response = self.client.post( + f"/api/v1/employees/{operator.id}/reset-password/", + data=json.dumps({"mode": "show"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 409) operator.refresh_from_db() self.assertEqual(operator.password, password_hash) - self.assertFalse(operator.must_change_password) def test_owner_unblocks_operator(self) -> None: operator = HumanUser.objects.get(email="staff.member@example.org") diff --git a/apps/backend/chatballs/integrations/serializers.py b/apps/backend/chatballs/integrations/serializers.py index d2c9fb4..4e446ee 100644 --- a/apps/backend/chatballs/integrations/serializers.py +++ b/apps/backend/chatballs/integrations/serializers.py @@ -1,14 +1,26 @@ from chatballs.integrations.models import Integration +def secret_mask(secret: str) -> str: + """Маска секрета для колонки «Секрет» (кадр N3): только публичный префикс + ключа («sk-or-»), сам секрет наружу не отдаётся.""" + if not secret: + return "" + head = secret[:8] + cut = head.rfind("-") + prefix = head[: cut + 1] if cut > 0 else "" + return f"{prefix}••••••••" + + def integration_payload(integration: Integration) -> dict[str, object]: - # Секрет не возвращаем; отдаём только признак его наличия. + # Секрет не возвращаем; отдаём признак его наличия и маску префикса. payload = { "id": integration.id, "kind": integration.kind, "provider": integration.provider, "name": integration.name, "hasSecret": bool(integration.secret), + "secretMasked": secret_mask(integration.secret), "isActive": integration.is_active, "config": { "baseUrl": integration.config.get("base_url", ""), diff --git a/apps/backend/chatballs/tenancy/migrations/0026_contact_merge_rls.py b/apps/backend/chatballs/tenancy/migrations/0026_contact_merge_rls.py new file mode 100644 index 0000000..451e38a --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0026_contact_merge_rls.py @@ -0,0 +1,47 @@ +from django.db import migrations + + +FORWARD_SQL = """ +ALTER TABLE conversations_contactmerge OWNER TO chatballs_schema; +ALTER TABLE conversations_contactmerge ENABLE ROW LEVEL SECURITY; +ALTER TABLE conversations_contactmerge FORCE ROW LEVEL SECURITY; +REVOKE ALL ON conversations_contactmerge FROM PUBLIC; + +GRANT SELECT, INSERT, UPDATE, DELETE + ON conversations_contactmerge TO chatballs_runtime_app; +GRANT ALL ON conversations_contactmerge TO chatballs_schema; +GRANT USAGE, SELECT ON SEQUENCE conversations_contactmerge_id_seq + TO chatballs_runtime_app, chatballs_schema; + +DROP POLICY IF EXISTS chatballs_tenant_isolation ON conversations_contactmerge; +CREATE POLICY chatballs_tenant_isolation ON conversations_contactmerge + FOR ALL TO chatballs_runtime_app + USING (organization_id = chatballs.current_organization_id()) + WITH CHECK (organization_id = chatballs.current_organization_id()); + +DROP POLICY IF EXISTS chatballs_schema_access ON conversations_contactmerge; +CREATE POLICY chatballs_schema_access ON conversations_contactmerge + FOR ALL TO chatballs_schema + USING (true) + WITH CHECK (true); +""" + + +REVERSE_SQL = """ +DROP POLICY IF EXISTS chatballs_tenant_isolation ON conversations_contactmerge; +DROP POLICY IF EXISTS chatballs_schema_access ON conversations_contactmerge; +ALTER TABLE conversations_contactmerge NO FORCE ROW LEVEL SECURITY; +ALTER TABLE conversations_contactmerge DISABLE ROW LEVEL SECURITY; +REVOKE ALL ON conversations_contactmerge FROM chatballs_runtime_app; +REVOKE USAGE, SELECT ON SEQUENCE conversations_contactmerge_id_seq + FROM chatballs_runtime_app; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0025_storage_settings"), + ("conversations", "0018_contact_merge"), + ] + + operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)] diff --git a/apps/backend/chatballs_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py index 9edd59b..08ada57 100644 --- a/apps/backend/chatballs_backend/settings_base.py +++ b/apps/backend/chatballs_backend/settings_base.py @@ -76,6 +76,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "chatballs.identity.middleware.SessionActivityMiddleware", "chatballs.events.middleware.CorrelationIdMiddleware", "chatballs.tenancy.middleware.TenantContextMiddleware", ] diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index c8e4934..72b7770 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -84,7 +84,7 @@ export function App() { if (activeUser && !initialRoute.organizationPublicId) { const nextPath = pathFromRoute( initialRoute.route, - initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId, + initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.settingsSection, initialRoute.productCode, activeUser.organizationPublicId, ); @@ -170,7 +170,7 @@ export function App() { ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> + navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> )} ); diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index 4c74656..98b7a7d 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -10,6 +10,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser { fullName: "Test User", mustChangePassword: false, totpEnabled: false, + totpLastUsedAt: null, deliveryMode: "CLOUD", uiTheme: "SYSTEM", uiAccent: "", @@ -23,6 +24,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser { totpRequired: false, capabilities, groups: [], + joinedAt: "2026-01-01T00:00:00Z", }; } @@ -44,7 +46,8 @@ describe("role-based navigation (SPEC-HUB-0031 §3)", () => { expect(isManager(user)).toBe(false); expect(canAccess(user, "chat")).toBe(true); expect(canAccess(user, "profile")).toBe(true); - expect(canAccess(user, "settings")).toBe(true); + // «Настройки» — настройки организации, сотруднику недоступны (дизайн-базлайн v2). + expect(canAccess(user, "settings")).toBe(false); expect(canAccess(user, "employees")).toBe(false); expect(canAccess(user, "agents")).toBe(false); expect(canAccess(user, "aiKnowledge")).toBe(false); diff --git a/apps/internal-ui/src/auth/access.ts b/apps/internal-ui/src/auth/access.ts index 1364b37..b35c382 100644 --- a/apps/internal-ui/src/auth/access.ts +++ b/apps/internal-ui/src/auth/access.ts @@ -7,7 +7,6 @@ import type { RouteKey, SessionUser } from "../types"; const EMPLOYEE_ROUTES: ReadonlySet = new Set([ "chat", "profile", - "settings", ]); export function isManager(user: SessionUser): boolean { @@ -19,7 +18,9 @@ export function hasCapability(user: SessionUser, capability: string): boolean { } export function canAccess(user: SessionUser, route: RouteKey): boolean { - if (route === "profile" || route === "settings") return true; + // «Настройки» — настройки организации: только владелец и админ. Личные + // параметры сотрудника живут на странице «Профиль» (дизайн-базлайн v2). + if (route === "profile") return true; if (isManager(user)) return true; return EMPLOYEE_ROUTES.has(route); } diff --git a/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx b/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx index b16c18b..b05ab2f 100644 --- a/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx +++ b/apps/internal-ui/src/features/administration/OrganizationLogoField.tsx @@ -30,39 +30,38 @@ export function OrganizationLogoField({
Логотип - PNG, JPEG или WebP · до 2 МБ - {!disabled && ( -
- { - const file = event.target.files?.[0]; - if (file) onUpload(file); - event.target.value = ""; - }} - /> - - {logoUrl && ( - - )} -
- )} + PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах.
+ {!disabled && ( +
+ { + const file = event.target.files?.[0]; + if (file) onUpload(file); + event.target.value = ""; + }} + /> + + +
+ )} setConfirmingRemoval(false)} diff --git a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx index a73275e..c105348 100644 --- a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx +++ b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx @@ -1,10 +1,16 @@ 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 { OrganizationSettings } from "./model"; import { OrganizationLogoField } from "./OrganizationLogoField"; +function savedLabel(updatedAt: string | null): string { + if (!updatedAt) return "Ещё не сохранялось"; + return `Сохранено ${shortDateTime(updatedAt)}`; +} + export function OrganizationSettingsForm({ organization, canManage, @@ -44,35 +50,36 @@ export function OrganizationSettingsForm({ onRemove={onRemoveLogo} />
- onChange({ ...organization, name }) - : undefined} - /> +
+ onChange({ ...organization, name }) + : undefined} + /> +
onChange({ ...organization, timezone })} - options={timezones.map((timezone) => [timezone, timezone])} + options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} + /> + onChange({ ...organization, currency })} + options={[["RUB", "Российский рубль (RUB)"]]} /> -
- onChange({ ...organization, currency })} - options={[["RUB", "Российский рубль (RUB)"]]} - /> -
{error &&
{error}
} - {message &&
{message}
} {canManage && (
+ {/* «Сохранено 2 сен, 14:12» — подпись у кнопки (кадр N1). */} + {message || savedLabel(organization.updatedAt)} }, + ]; return ( -
-
-
-
-

{card.name}

- +
+ + +
+ + + +
+
+

{card.name}

+ {status.text}
-
- {card.groupName ?? "Без группы"} - · +

+ {card.groupName ?? "Без группы"} + {card.code} -

+ + {openDialogsLine(card.counters.openConversations)} + + создан {createdLabel(card.createdAt)} +

{canManage && ( -
- + + +
)}
+ {card.providerIntegrationId === null && ( +
+ +
+ AI не может отвечать: провайдер не выбран + Добавьте ключ в «Настройки → AI-провайдер» и выберите провайдера в блоке «Модель». До этого диалоги агента ждут человека. +
+ {canManage && ( + + )} +
+ )} + {feedback &&
{feedback.text}
} - - - - - item.channel === null)} - openIntegrations={openIntegrations} - bind={bind} - unbind={unbind} - /> +
+
+ + +
+
+ + item.channel === null)} + openIntegrations={openIntegrations} + bind={bind} + unbind={unbind} + /> + +
+
{canManage && ( -
- - + + + Удалить можно только без диалогов и подключений
)} @@ -268,9 +314,129 @@ export function AgentDetailPage({ ); } -function AssignmentSection({ - card, groups, canManage, busy, apply, -}: { +// --- Инструкции (кадры G3/G5) --- + +const INSTRUCTION_FIELDS = [ + { key: "persona", label: "Кто он и что делает", hint: "персонализация", rows: 2, placeholder: "" }, + { key: "tone", label: "Как он должен говорить", hint: "тон", rows: 1, placeholder: "Например: спокойно и вежливо, на «вы»" }, + { key: "instructions", label: "Правила работы", hint: "инструкции", rows: 3, placeholder: "Например: не называй цены, направляй к консультанту" }, +] as const; + +function InstructionsCard({ card, canManage, busy, apply }: { + card: AgentCard; + canManage: boolean; + busy: boolean; + apply: (patch: AgentPatch) => Promise; +}) { + const [draft, setDraft] = useState({ persona: card.persona, tone: card.tone, instructions: card.instructions }); + useEffect(() => { + setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions }); + }, [card.persona, card.tone, card.instructions]); + const dirty = draft.persona !== card.persona || draft.tone !== card.tone || draft.instructions !== card.instructions; + + return ( +
+
+

Инструкции

+ Системный промпт собирается из трёх частей в этом порядке +
+
+ {INSTRUCTION_FIELDS.map((field) => ( +