From 07f4cd5f3465d026e9c0832a264f1eaed3d8f964 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 14:01:30 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(identity):=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D1=8B=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D0=BE=D0=B2=20(=D1=8D?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=202=20=D0=BF=D0=B8=D0=B2=D0=BE=D1=82=D0=B0,?= =?UTF-8?q?=20ADR-HUB-0043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отделы и capability/scope-модель полностью удалены: - снесены Department, AccessProfile, EmployeeAccessAssignment и scope-авторизация; - новые EmployeeGroup/EmployeeGroupMember: настраиваемые группы, CRUD у OWNER/ADMIN; - ролевая policy: OWNER == ADMIN (кроме ownership.transfer), EMPLOYEE ограничен чатом; - группа у канала и диалога (наследуется при ingest, переносится вручную), видимость EMPLOYEE: свои группы + без группы + назначенные ему диалоги; - эндпоинты /company/groups/, /conversations/{id}/group/ и /assignee/; - звонки проверяют групповую видимость диалога (закрыта дыра в calls); - RLS/гранты/триггеры групп (tenancy/0018), вьюха порталов без отделов (tenancy/0019); - знания: библиотека общая для организации, visibility/departments удалены; - фронт: employees/channels/knowledge переведены на группы, features/departments удалён. Backend 564 теста OK (докер, миграции с нуля), frontend tsc + vitest 91 OK. Co-Authored-By: Claude Fable 5 --- apps/backend/hub_backend/urls_app.py | 4 - apps/backend/hub_platform/ai/admin.py | 3 +- .../hub_platform/ai/agent_knowledge.py | 31 +- apps/backend/hub_platform/ai/bulk_views.py | 27 -- .../hub_platform/ai/knowledge_api_inputs.py | 47 ---- .../backend/hub_platform/ai/knowledge_bulk.py | 71 +---- .../hub_platform/ai/knowledge_conflicts.py | 110 -------- .../hub_platform/ai/knowledge_import.py | 99 +------ .../hub_platform/ai/knowledge_models.py | 38 --- .../hub_platform/ai/knowledge_policy.py | 125 ++------ .../ai/knowledge_policy_test_base.py | 117 +------- .../hub_platform/ai/knowledge_services.py | 24 -- .../hub_platform/ai/knowledge_types.py | 8 - .../hub_platform/ai/knowledge_visibility.py | 87 ------ ...knowledgedepartment_department_and_more.py | 48 ++++ apps/backend/hub_platform/ai/models.py | 24 +- apps/backend/hub_platform/ai/selectors.py | 37 +-- apps/backend/hub_platform/ai/serializers.py | 20 +- .../hub_platform/ai/test_agent_attachments.py | 76 ++--- .../ai/test_agent_knowledge_guards.py | 114 ++++---- .../hub_platform/ai/test_knowledge_api_v2.py | 89 +----- .../hub_platform/ai/test_knowledge_bulk.py | 77 +---- .../ai/test_knowledge_bulk_policy.py | 64 ----- .../ai/test_knowledge_category_api.py | 15 +- .../ai/test_knowledge_import_v2.py | 122 ++------ .../hub_platform/ai/test_knowledge_policy.py | 104 +++---- .../ai/test_knowledge_policy_api.py | 91 +++--- .../ai/test_knowledge_scope_conflicts.py | 266 ------------------ .../ai/test_knowledge_selectors.py | 139 ++++----- .../ai/test_knowledge_visibility.py | 151 ---------- apps/backend/hub_platform/ai/tests.py | 1 - apps/backend/hub_platform/ai/urls.py | 5 - apps/backend/hub_platform/ai/views.py | 14 +- apps/backend/hub_platform/api/permissions.py | 32 +-- .../backend/hub_platform/calls/permissions.py | 6 +- .../hub_platform/calls/tests/helpers.py | 9 +- .../hub_platform/calls/tests/test_api.py | 2 +- .../hub_platform/calls/tests/test_services.py | 2 +- apps/backend/hub_platform/channels/admin.py | 2 +- .../hub_platform/channels/api_inputs.py | 2 +- .../hub_platform/channels/authorization.py | 73 +---- .../0006_remove_channel_department.py | 19 ++ .../channels/migrations/0007_channel_group.py | 20 ++ apps/backend/hub_platform/channels/models.py | 7 +- .../hub_platform/channels/selectors.py | 28 +- .../hub_platform/channels/serializers.py | 5 +- .../backend/hub_platform/channels/services.py | 52 ++-- apps/backend/hub_platform/channels/tests.py | 161 +++-------- apps/backend/hub_platform/channels/views.py | 8 +- .../hub_platform/conversations/clients.py | 23 +- .../hub_platform/conversations/command.py | 87 +++--- .../hub_platform/conversations/ingest.py | 6 +- .../migrations/0010_conversation_group.py | 20 ++ .../hub_platform/conversations/models.py | 10 + .../conversations/reporting_views.py | 27 +- .../hub_platform/conversations/selectors.py | 41 ++- .../hub_platform/conversations/stats.py | 40 +-- .../conversations/test_authorization.py | 128 ++++++--- .../hub_platform/conversations/tests.py | 23 +- .../hub_platform/conversations/urls.py | 2 + .../hub_platform/conversations/views.py | 90 +++++- .../hub_platform/identity/access_defaults.py | 70 ----- .../hub_platform/identity/access_models.py | 153 ---------- .../hub_platform/identity/access_payloads.py | 61 ---- .../hub_platform/identity/access_services.py | 59 ---- .../hub_platform/identity/access_urls.py | 9 - .../hub_platform/identity/access_views.py | 253 ----------------- apps/backend/hub_platform/identity/admin.py | 14 +- .../hub_platform/identity/auth/common.py | 7 +- .../hub_platform/identity/bootstrap.py | 49 ++-- .../hub_platform/identity/capabilities.py | 142 ++++------ .../hub_platform/identity/company_urls.py | 5 +- .../hub_platform/identity/company_views.py | 56 ---- .../identity/demo_seed/data/catalog.json | 12 +- .../identity/demo_seed/data/channels_ai.json | 140 +++++++-- .../identity/demo_seed/data/operations.json | 37 ++- .../identity/demo_seed/data/organization.json | 22 +- .../identity/demo_seed/loaders/catalog.py | 8 +- .../identity/demo_seed/loaders/channels_ai.py | 4 +- .../identity/demo_seed/loaders/foundation.py | 36 +-- .../identity/demo_seed/loaders/operations.py | 1 - .../identity/demo_seed/loaders/support.py | 2 - .../hub_platform/identity/demo_seed/refs.py | 2 +- .../hub_platform/identity/employee_support.py | 35 +-- .../hub_platform/identity/employee_urls.py | 3 - .../identity/employee_validation.py | 23 +- .../hub_platform/identity/employee_views.py | 100 +++---- .../hub_platform/identity/governance.py | 79 +++--- .../hub_platform/identity/group_models.py | 71 +++++ .../hub_platform/identity/group_views.py | 155 ++++++++++ .../identity/invitation_service.py | 1 - .../management/commands/bootstrap_owner.py | 2 +- .../commands/verify_employee_authorization.py | 56 ---- ...loyeegroup_employeegroupmember_and_more.py | 174 ++++++++++++ apps/backend/hub_platform/identity/models.py | 52 +--- .../hub_platform/identity/ownership_views.py | 19 +- apps/backend/hub_platform/identity/policy.py | 174 +++--------- .../identity/system_departments.py | 30 -- .../hub_platform/identity/test_access_api.py | 248 ---------------- .../identity/test_authorization.py | 198 ++++--------- .../identity/test_channel_capabilities.py | 262 ----------------- .../identity/test_membership_identity.py | 7 - apps/backend/hub_platform/identity/tests.py | 116 +++----- .../0008_remove_notification_department.py | 19 ++ .../hub_platform/notifications/models.py | 7 - .../hub_platform/notifications/selectors.py | 12 +- .../hub_platform/notifications/services.py | 4 - .../hub_platform/notifications/tests.py | 18 +- .../platform/provisioning_service.py | 5 +- .../platform/tests/test_provisioning.py | 5 - apps/backend/hub_platform/products/admin.py | 8 +- .../0012_delete_productdepartment.py | 18 ++ apps/backend/hub_platform/products/models.py | 17 -- .../hub_platform/products/selectors.py | 1 - .../hub_platform/products/serializers.py | 4 - .../backend/hub_platform/products/services.py | 19 +- apps/backend/hub_platform/products/tests.py | 11 +- apps/backend/hub_platform/products/views.py | 40 --- .../subscriptions/tests/test_reservations.py | 2 - apps/backend/hub_platform/support/messages.py | 2 - .../hub_platform/support/public_views.py | 1 - apps/backend/hub_platform/support/session.py | 4 +- .../support/test_widget_messages.py | 8 +- apps/backend/hub_platform/support/tests.py | 12 +- apps/backend/hub_platform/support/views.py | 6 - .../0008_remove_supportportal_department.py | 19 ++ .../hub_platform/support_portals/models.py | 17 +- .../support_portals/portal_services.py | 13 +- .../support_portals/portal_views.py | 3 - .../support_portals/public_views.py | 1 - .../hub_platform/support_portals/selectors.py | 2 - .../support_portals/serializers.py | 1 - .../support_portals/tests/base.py | 1 - .../tests/test_management_api.py | 64 +---- .../support_portals/tests/test_public_api.py | 1 - .../support_portals/widget_validation.py | 15 +- .../hub_platform/tenancy/middleware.py | 2 +- .../migrations/0018_employee_groups_rls.py | 85 ++++++ ...ort_portal_directory_without_department.py | 61 ++++ .../tenancy/test_platform_grants.py | 9 +- apps/backend/hub_platform/tenancy/test_rls.py | 13 +- apps/backend/hub_platform/tenancy/tests.py | 10 +- apps/backend/hub_platform/webchat/models.py | 4 +- apps/internal-ui/src/App.tsx | 21 +- apps/internal-ui/src/auth/access.test.ts | 167 ++++------- apps/internal-ui/src/auth/access.ts | 86 ++---- .../src/features/ai/agentOptions.ts | 23 +- .../ai/knowledge/KnowledgeBulkActions.tsx | 55 +--- .../ai/knowledge/KnowledgeBulkDialog.tsx | 55 +--- .../ai/knowledge/KnowledgeCreatePage.tsx | 5 +- .../knowledge/KnowledgeDepartmentPicker.tsx | 67 ----- .../ai/knowledge/KnowledgeDetailPage.tsx | 13 +- .../ai/knowledge/KnowledgeDetailSidebar.tsx | 4 +- .../ai/knowledge/KnowledgeEditorCard.tsx | 6 +- .../ai/knowledge/KnowledgeEditorFields.tsx | 58 +--- .../features/ai/knowledge/KnowledgePage.tsx | 59 +--- .../ai/knowledge/KnowledgePageHeader.tsx | 2 +- .../knowledge/KnowledgeScopeConflictModal.tsx | 38 --- .../features/ai/knowledge/KnowledgeTable.tsx | 11 - .../ai/knowledge/KnowledgeToolbar.tsx | 26 -- .../src/features/ai/knowledge/api.test.ts | 31 +- .../src/features/ai/knowledge/api.ts | 22 +- .../ai/knowledge/knowledgeEditorModel.test.ts | 23 +- .../ai/knowledge/knowledgeEditorModel.ts | 13 +- .../src/features/ai/knowledge/styles-bulk.css | 13 - .../features/ai/knowledge/styles-editor.css | 35 --- .../features/ai/knowledge/styles-library.css | 54 +--- .../src/features/ai/knowledge/types.ts | 33 --- .../ai/knowledge/useKnowledgeLibrary.ts | 19 +- apps/internal-ui/src/features/ai/model.ts | 2 +- .../channels/ChannelAssignmentSection.tsx | 32 +-- .../features/channels/ChannelCreateWizard.tsx | 22 +- .../features/channels/ChannelDetailHeader.tsx | 2 +- .../features/channels/ChannelDetailPage.tsx | 52 ++-- .../channels/ChannelPolicySection.tsx | 6 +- .../src/features/channels/ChannelRow.tsx | 2 +- .../channels/ChannelWizardChannelStep.tsx | 23 +- .../channels/ChannelWizardSummary.tsx | 6 +- .../src/features/channels/ChannelsPage.tsx | 30 +- apps/internal-ui/src/features/channels/api.ts | 6 +- .../features/channels/channel-editor.test.ts | 9 +- .../src/features/channels/channel-editor.ts | 8 +- .../src/features/channels/model.test.ts | 29 +- .../src/features/channels/model.ts | 37 +-- .../src/features/channels/types.ts | 5 +- .../src/features/channels/useChannelEditor.ts | 2 +- .../src/features/channels/useChannelsPage.ts | 6 +- .../command/center/CompanyStatusBanner.tsx | 2 +- .../command/center/SalesDepartmentCard.tsx | 2 +- .../src/features/command/center/model.ts | 2 +- .../conversations/ConversationWorkspace.tsx | 15 +- .../src/features/conversations/model.ts | 7 +- .../features/departments/DepartmentCard.tsx | 82 ------ .../features/departments/DepartmentsPage.tsx | 58 ---- .../src/features/departments/model.ts | 38 --- .../src/features/departments/styles.css | 210 -------------- .../departments/useDepartmentStats.ts | 47 ---- .../employees/AccessProfileEditor.tsx | 83 ------ .../features/employees/AccessProfileList.tsx | 15 - .../features/employees/AccessProfilesPage.tsx | 30 -- .../employees/EmployeeAccessSection.tsx | 18 -- .../employees/EmployeeCreateDrawer.tsx | 59 ++-- .../employees/EmployeeDetailHeader.tsx | 9 +- .../features/employees/EmployeeDetailPage.tsx | 16 +- .../employees/EmployeeDetailSections.tsx | 12 +- .../employees/EmployeeIdentitySections.tsx | 28 +- .../src/features/employees/EmployeeTable.tsx | 19 +- .../features/employees/EmployeesFilters.tsx | 26 +- .../features/employees/EmployeesListPage.tsx | 26 +- .../src/features/employees/EmployeesPage.tsx | 1 - .../src/features/employees/access-api.ts | 50 ---- .../src/features/employees/access-model.ts | 46 --- .../src/features/employees/model.test.ts | 72 +---- .../src/features/employees/model.ts | 32 +-- .../src/features/sales/SalesDialogsPage.tsx | 1 - .../support-portals/PortalContent.tsx | 2 +- .../SupportPortalDetailPage.tsx | 2 +- .../support-portals/SupportPortalsPage.tsx | 2 +- .../src/features/support-portals/model.ts | 1 - .../features/support/SupportDialogsPage.tsx | 1 - .../src/layout/ShellRouteContent.tsx | 23 +- apps/internal-ui/src/layout/Sidebar.tsx | 2 +- apps/internal-ui/src/layout/TopBar.tsx | 3 +- apps/internal-ui/src/router.test.ts | 7 - apps/internal-ui/src/router.ts | 4 - apps/internal-ui/src/routes.ts | 2 - .../content-library/AgentLinkDialog.tsx | 4 +- apps/internal-ui/src/types.ts | 84 ++---- 228 files changed, 2438 insertions(+), 6587 deletions(-) delete mode 100644 apps/backend/hub_platform/ai/knowledge_conflicts.py delete mode 100644 apps/backend/hub_platform/ai/knowledge_visibility.py create mode 100644 apps/backend/hub_platform/ai/migrations/0014_remove_knowledgedepartment_department_and_more.py delete mode 100644 apps/backend/hub_platform/ai/test_knowledge_bulk_policy.py delete mode 100644 apps/backend/hub_platform/ai/test_knowledge_scope_conflicts.py delete mode 100644 apps/backend/hub_platform/ai/test_knowledge_visibility.py create mode 100644 apps/backend/hub_platform/channels/migrations/0006_remove_channel_department.py create mode 100644 apps/backend/hub_platform/channels/migrations/0007_channel_group.py create mode 100644 apps/backend/hub_platform/conversations/migrations/0010_conversation_group.py delete mode 100644 apps/backend/hub_platform/identity/access_defaults.py delete mode 100644 apps/backend/hub_platform/identity/access_models.py delete mode 100644 apps/backend/hub_platform/identity/access_payloads.py delete mode 100644 apps/backend/hub_platform/identity/access_services.py delete mode 100644 apps/backend/hub_platform/identity/access_urls.py delete mode 100644 apps/backend/hub_platform/identity/access_views.py delete mode 100644 apps/backend/hub_platform/identity/company_views.py create mode 100644 apps/backend/hub_platform/identity/group_models.py create mode 100644 apps/backend/hub_platform/identity/group_views.py delete mode 100644 apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py create mode 100644 apps/backend/hub_platform/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py delete mode 100644 apps/backend/hub_platform/identity/system_departments.py delete mode 100644 apps/backend/hub_platform/identity/test_access_api.py delete mode 100644 apps/backend/hub_platform/identity/test_channel_capabilities.py create mode 100644 apps/backend/hub_platform/notifications/migrations/0008_remove_notification_department.py create mode 100644 apps/backend/hub_platform/products/migrations/0012_delete_productdepartment.py create mode 100644 apps/backend/hub_platform/support_portals/migrations/0008_remove_supportportal_department.py create mode 100644 apps/backend/hub_platform/tenancy/migrations/0018_employee_groups_rls.py create mode 100644 apps/backend/hub_platform/tenancy/migrations/0019_support_portal_directory_without_department.py delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeDepartmentPicker.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeScopeConflictModal.tsx delete mode 100644 apps/internal-ui/src/features/departments/DepartmentCard.tsx delete mode 100644 apps/internal-ui/src/features/departments/DepartmentsPage.tsx delete mode 100644 apps/internal-ui/src/features/departments/model.ts delete mode 100644 apps/internal-ui/src/features/departments/styles.css delete mode 100644 apps/internal-ui/src/features/departments/useDepartmentStats.ts delete mode 100644 apps/internal-ui/src/features/employees/AccessProfileEditor.tsx delete mode 100644 apps/internal-ui/src/features/employees/AccessProfileList.tsx delete mode 100644 apps/internal-ui/src/features/employees/AccessProfilesPage.tsx delete mode 100644 apps/internal-ui/src/features/employees/EmployeeAccessSection.tsx delete mode 100644 apps/internal-ui/src/features/employees/access-api.ts delete mode 100644 apps/internal-ui/src/features/employees/access-model.ts diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/hub_backend/urls_app.py index 621a83d..43b889d 100644 --- a/apps/backend/hub_backend/urls_app.py +++ b/apps/backend/hub_backend/urls_app.py @@ -23,10 +23,6 @@ urlpatterns = [ "api/v1/organizations//employees/", include("hub_platform.identity.employee_urls"), ), - path( - "api/v1/organizations//access-profiles/", - include("hub_platform.identity.access_urls"), - ), path("api/v1/organizations//ai/", include("hub_platform.ai.urls")), path( "api/v1/organizations//integrations/", diff --git a/apps/backend/hub_platform/ai/admin.py b/apps/backend/hub_platform/ai/admin.py index b6243ca..6d1d725 100644 --- a/apps/backend/hub_platform/ai/admin.py +++ b/apps/backend/hub_platform/ai/admin.py @@ -27,10 +27,9 @@ class KnowledgeAdmin(admin.ModelAdmin): "title", "organization", "category", - "visibility", "is_enabled", "updated_at", ) - list_filter = ("visibility", "is_enabled") + list_filter = ("is_enabled",) search_fields = ("title", "description") inlines = [KnowledgeAttachmentInline] diff --git a/apps/backend/hub_platform/ai/agent_knowledge.py b/apps/backend/hub_platform/ai/agent_knowledge.py index ff14cb3..dcb15ed 100644 --- a/apps/backend/hub_platform/ai/agent_knowledge.py +++ b/apps/backend/hub_platform/ai/agent_knowledge.py @@ -1,45 +1,28 @@ -from django.db.models import Q, QuerySet +from django.db.models import QuerySet -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.models import AIAgent, Knowledge from hub_platform.channels.models import Channel from hub_platform.support_portals.models import PortalArticle from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus +# Библиотека знаний — общая для организации (ADR-HUB-0041 §8): агент использует +# только явно выбранные и включённые знания, областей видимости нет. + def knowledge_available_to_channel( queryset: QuerySet[Knowledge], *, channel: Channel ) -> QuerySet[Knowledge]: - """Apply the single department-availability predicate for an agent channel.""" - queryset = queryset.filter(organization_id=channel.organization_id) - if channel.department_id is None: - return queryset.filter(visibility=KnowledgeVisibility.ORGANIZATION) - return queryset.filter( - Q(visibility=KnowledgeVisibility.ORGANIZATION) - | Q( - visibility=KnowledgeVisibility.DEPARTMENTS, - department_links__department_id=channel.department_id, - ) - ).distinct() + return queryset.filter(organization_id=channel.organization_id) def portal_articles_available_to_channel( queryset: QuerySet[PortalArticle], *, channel: Channel ) -> QuerySet[PortalArticle]: - """Статьи портала доступны агенту канала того же отдела. - - Портал по определению живёт в отделе поддержки, поэтому агент вне этого - отдела статьи не получает — то же правило отделовой доступности, что и у - знаний библиотеки (ADR-HUB-0036). - """ - queryset = queryset.filter(organization_id=channel.organization_id) - if channel.department_id is None: - return queryset.none() - return queryset.filter(portal__department_id=channel.department_id) + return queryset.filter(organization_id=channel.organization_id) def runtime_knowledge_for_agent(agent: AIAgent) -> QuerySet[Knowledge]: - """Return explicitly selected, department-compatible, enabled knowledge.""" + """Явно выбранные и включённые знания агента.""" selected = Knowledge.objects.filter(agents=agent, is_enabled=True) return knowledge_available_to_channel(selected, channel=agent.channel) diff --git a/apps/backend/hub_platform/ai/bulk_views.py b/apps/backend/hub_platform/ai/bulk_views.py index 77d360f..bdf6614 100644 --- a/apps/backend/hub_platform/ai/bulk_views.py +++ b/apps/backend/hub_platform/ai/bulk_views.py @@ -12,9 +12,7 @@ from hub_platform.ai.api_errors import validation_error_response from hub_platform.ai.knowledge_bulk import ( add_category_knowledge_to_agent, bulk_move_knowledge, - bulk_replace_knowledge_visibility, ) -from hub_platform.ai.knowledge_conflicts import KnowledgeScopeConflict from hub_platform.ai.models import AIAgent from hub_platform.ai.selectors import agent_for_employee from hub_platform.api.permissions import HasCapability, HasEntitlement @@ -34,13 +32,6 @@ def _knowledge_ids(body: dict[str, object]) -> list[int]: return [_positive_id(item, "knowledgeIds") for item in raw_ids] -def _department_ids(body: dict[str, object]) -> list[int]: - raw_ids = body.get("departmentIds", []) - if not isinstance(raw_ids, list): - raise ValidationError({"departmentIds": "List of department IDs required"}) - return [_positive_id(item, "departmentIds") for item in raw_ids] - - def _audit_bulk(request: Request, action: str, object_ids: list[int]) -> None: record_audit_event( action=action, @@ -75,24 +66,6 @@ class KnowledgeBulkMoveView(_KnowledgeBulkView): return Response({"updated": len(updated_ids), "knowledgeIds": updated_ids}) -class KnowledgeBulkVisibilityView(_KnowledgeBulkView): - def post(self, request: Request) -> Response: - try: - knowledge_ids = _knowledge_ids(request.data) - updated_ids = bulk_replace_knowledge_visibility( - context=request.tenant_context, - knowledge_ids=knowledge_ids, - visibility=str(request.data.get("visibility", "")), - department_ids=_department_ids(request.data), - ) - except KnowledgeScopeConflict as error: - return Response(error.payload(), status=409) - except ValidationError as error: - return validation_error_response(error) - _audit_bulk(request, "ai.knowledge_bulk_visibility_replaced", updated_ids) - return Response({"updated": len(updated_ids), "knowledgeIds": updated_ids}) - - def _attach_action(body: dict[str, object]) -> bool: action = str(body.get("action", "attach")) if action not in {"attach", "detach"}: diff --git a/apps/backend/hub_platform/ai/knowledge_api_inputs.py b/apps/backend/hub_platform/ai/knowledge_api_inputs.py index 8b4e19f..d04f2d3 100644 --- a/apps/backend/hub_platform/ai/knowledge_api_inputs.py +++ b/apps/backend/hub_platform/ai/knowledge_api_inputs.py @@ -1,7 +1,6 @@ from django.core.exceptions import ValidationError from rest_framework.request import Request -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.knowledge_services import KnowledgeInput from hub_platform.ai.models import Knowledge from hub_platform.ai.selectors import KnowledgeFilters @@ -34,49 +33,12 @@ def _category_id(body: dict[str, object], current: Knowledge | None) -> int | No return _positive_id(raw, "category") -def _department_ids(body: dict[str, object], current: Knowledge | None) -> tuple[int, ...]: - if "departmentIds" in body: - raw_items = body["departmentIds"] - elif "departments" in body: - raw_items = body["departments"] - elif current is not None: - return tuple( - current.department_links.values_list("department_id", flat=True) - ) - else: - return () - if not isinstance(raw_items, list): - raise ValidationError({"departments": "List of department ids required"}) - normalized: list[int] = [] - for item in raw_items: - raw_id = item.get("id") if isinstance(item, dict) else item - department_id = _positive_id(raw_id, "departments") - if department_id not in normalized: - normalized.append(department_id) - return tuple(normalized) - - def knowledge_input( body: dict[str, object], *, current: Knowledge | None = None ) -> KnowledgeInput: raw_enabled = body.get("isEnabled", current.is_enabled if current else True) if not isinstance(raw_enabled, bool): raise ValidationError({"isEnabled": "Boolean required"}) - visibility = str( - body.get( - "visibility", - current.visibility if current else KnowledgeVisibility.ORGANIZATION, - ) - ) - if visibility not in KnowledgeVisibility.values: - raise ValidationError({"visibility": "Unknown knowledge visibility"}) - department_ids = _department_ids(body, current) - if ( - visibility == KnowledgeVisibility.ORGANIZATION - and "departmentIds" not in body - and "departments" not in body - ): - department_ids = () return KnowledgeInput( title=str(body.get("title", current.title if current else "")), description=str( @@ -85,15 +47,10 @@ def knowledge_input( content=str(body.get("content", current.content if current else "")), is_enabled=raw_enabled, category_id=_category_id(body, current), - visibility=visibility, - department_ids=department_ids, ) def knowledge_filters(request: Request) -> KnowledgeFilters: - visibility = request.query_params.get("visibility") or None - if visibility is not None and visibility not in KnowledgeVisibility.values: - raise ValidationError({"visibility": "Unknown knowledge visibility"}) raw_enabled = request.query_params.get("isEnabled") if raw_enabled in (None, ""): is_enabled = None @@ -107,10 +64,6 @@ def knowledge_filters(request: Request) -> KnowledgeFilters: category_id=_optional_query_id( request.query_params.get("category"), "category" ), - department_id=_optional_query_id( - request.query_params.get("department"), "department" - ), - visibility=visibility, is_enabled=is_enabled, search=request.query_params.get("search", ""), ) diff --git a/apps/backend/hub_platform/ai/knowledge_bulk.py b/apps/backend/hub_platform/ai/knowledge_bulk.py index bda3377..c0156c8 100644 --- a/apps/backend/hub_platform/ai/knowledge_bulk.py +++ b/apps/backend/hub_platform/ai/knowledge_bulk.py @@ -6,17 +6,11 @@ from django.db import transaction from django.utils import timezone from hub_platform.ai.agent_knowledge import knowledge_available_to_channel -from hub_platform.ai.knowledge_conflicts import ( - KnowledgeScopeConflict, - knowledge_scope_conflicts, -) from hub_platform.ai.knowledge_policy import employee_can_write_knowledge -from hub_platform.ai.knowledge_visibility import departments_for_scope from hub_platform.ai.models import ( AIAgent, Knowledge, KnowledgeCategory, - KnowledgeDepartment, ) from hub_platform.ai.services import knowledge_for_agent_ids from hub_platform.channels.models import Channel @@ -43,7 +37,6 @@ def _locked_knowledge(*, context: TenantContext, knowledge_ids: Iterable[int]) - organization_id=context.organization_id, id__in=normalized_ids, ) - .prefetch_related("department_links") .order_by("id") ) if len(items) != len(normalized_ids): @@ -55,17 +48,10 @@ def _require_bulk_write( *, context: TenantContext, items: Iterable[Knowledge], - visibility: str | None = None, - department_ids: Iterable[int] | None = None, ) -> None: for knowledge in items: - if not employee_can_write_knowledge( - context=context, - knowledge=knowledge, - visibility=visibility, - department_ids=department_ids, - ): - raise PermissionDenied("Knowledge scope is not manageable") + if not employee_can_write_knowledge(context=context, knowledge=knowledge): + raise PermissionDenied("Knowledge is not manageable") @transaction.atomic @@ -89,59 +75,6 @@ def bulk_move_knowledge( return [item.id for item in items] -@transaction.atomic -def bulk_replace_knowledge_visibility( - *, - context: TenantContext, - knowledge_ids: Iterable[int], - visibility: str, - department_ids: Iterable[int], -) -> list[int]: - departments = departments_for_scope( - context=context, - visibility=visibility, - department_ids=department_ids, - ) - target_department_ids = [department.id for department in departments] - items = _locked_knowledge(context=context, knowledge_ids=knowledge_ids) - _require_bulk_write( - context=context, - items=items, - visibility=visibility, - department_ids=target_department_ids, - ) - conflicts = tuple( - conflict - for knowledge in items - for conflict in knowledge_scope_conflicts( - knowledge=knowledge, - visibility=visibility, - department_ids=target_department_ids, - ) - ) - if conflicts: - raise KnowledgeScopeConflict(conflicts) - - item_ids = [item.id for item in items] - Knowledge.objects.filter(id__in=item_ids).update( - visibility=visibility, - updated_at=timezone.now(), - ) - KnowledgeDepartment.objects.filter(knowledge_id__in=item_ids).delete() - KnowledgeDepartment.objects.bulk_create( - [ - KnowledgeDepartment( - organization=context.organization, - knowledge=knowledge, - department=department, - ) - for knowledge in items - for department in departments - ] - ) - return item_ids - - @dataclass(frozen=True, slots=True) class CategorySelectionResult: agent: AIAgent diff --git a/apps/backend/hub_platform/ai/knowledge_conflicts.py b/apps/backend/hub_platform/ai/knowledge_conflicts.py deleted file mode 100644 index 5b876f8..0000000 --- a/apps/backend/hub_platform/ai/knowledge_conflicts.py +++ /dev/null @@ -1,110 +0,0 @@ -from collections.abc import Iterable -from dataclasses import dataclass - -from django.db.models import Q - -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.models import AIAgent, AIAgentStatus, Knowledge -from hub_platform.channels.models import Channel - - -@dataclass(frozen=True, slots=True) -class AgentKnowledgeConflict: - agent_id: int - agent_name: str - knowledge_id: int - knowledge_title: str - - def payload(self) -> dict[str, object]: - return { - "agent": {"id": self.agent_id, "name": self.agent_name}, - "knowledge": { - "id": self.knowledge_id, - "title": self.knowledge_title, - }, - } - - -class KnowledgeScopeConflict(Exception): - code = "agent_knowledge_scope_conflict" - - def __init__(self, conflicts: Iterable[AgentKnowledgeConflict]) -> None: - self.conflicts = tuple( - sorted( - conflicts, - key=lambda item: (item.agent_id, item.knowledge_id), - ) - ) - super().__init__("Knowledge scope conflicts with assigned agents") - - def payload(self) -> dict[str, object]: - return { - "code": self.code, - "detail": str(self), - "conflicts": [conflict.payload() for conflict in self.conflicts], - } - - -def _active_agents(): - return AIAgent.objects.exclude(status=AIAgentStatus.ARCHIVED) - - -def require_knowledge_scope_compatible( - *, knowledge: Knowledge, visibility: str, department_ids: Iterable[int] -) -> None: - conflicts = knowledge_scope_conflicts( - knowledge=knowledge, - visibility=visibility, - department_ids=department_ids, - ) - if conflicts: - raise KnowledgeScopeConflict(conflicts) - - -def knowledge_scope_conflicts( - *, knowledge: Knowledge, visibility: str, department_ids: Iterable[int] -) -> tuple[AgentKnowledgeConflict, ...]: - if visibility == KnowledgeVisibility.ORGANIZATION: - return () - allowed_department_ids = set(department_ids) - agents = ( - _active_agents() - .filter(knowledge_items=knowledge) - .filter( - Q(channel__department_id__isnull=True) - | ~Q(channel__department_id__in=allowed_department_ids) - ) - .order_by("id") - ) - return tuple( - AgentKnowledgeConflict( - agent_id=agent.id, - agent_name=agent.name, - knowledge_id=knowledge.id, - knowledge_title=knowledge.title, - ) - for agent in agents - ) - - -def require_channel_department_compatible(*, channel: Channel, department_id: int | None) -> None: - agent = _active_agents().filter(channel=channel).prefetch_related("knowledge_items").first() - if agent is None: - return - incompatible = agent.knowledge_items.filter(visibility=KnowledgeVisibility.DEPARTMENTS) - if department_id is not None: - compatible_ids = incompatible.filter(department_links__department_id=department_id).values( - "id" - ) - incompatible = incompatible.exclude(id__in=compatible_ids) - conflicts = [ - AgentKnowledgeConflict( - agent_id=agent.id, - agent_name=agent.name, - knowledge_id=knowledge.id, - knowledge_title=knowledge.title, - ) - for knowledge in incompatible.order_by("id") - ] - if conflicts: - raise KnowledgeScopeConflict(conflicts) diff --git a/apps/backend/hub_platform/ai/knowledge_import.py b/apps/backend/hub_platform/ai/knowledge_import.py index fd7396f..7c9e28c 100644 --- a/apps/backend/hub_platform/ai/knowledge_import.py +++ b/apps/backend/hub_platform/ai/knowledge_import.py @@ -3,7 +3,6 @@ from dataclasses import dataclass, field from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction -from hub_platform.ai.knowledge_conflicts import KnowledgeScopeConflict from hub_platform.ai.knowledge_policy import ( employee_can_write_knowledge, require_knowledge_create, @@ -13,10 +12,7 @@ from hub_platform.ai.knowledge_services import ( create_knowledge, update_knowledge, ) -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.knowledge_visibility import departments_for_scope from hub_platform.ai.models import Knowledge, KnowledgeCategory -from hub_platform.identity.models import Department, DepartmentStatus from hub_platform.tenancy.context import TenantContext @@ -82,38 +78,6 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge return category -def _visibility(document: dict[str, object], *, default: str) -> str: - if "visibility" not in document: - return default - value = document["visibility"] - if not isinstance(value, str) or value not in KnowledgeVisibility.values: - raise ValidationError({"visibility": "Unknown knowledge visibility"}) - return value - - -def _departments_for_codes(*, context: TenantContext, raw_codes: object) -> list[Department]: - if not isinstance(raw_codes, list): - raise ValidationError({"departmentCodes": "List of department codes required"}) - codes: list[str] = [] - for raw_code in raw_codes: - if not isinstance(raw_code, str) or not raw_code.strip(): - raise ValidationError({"departmentCodes": "Department codes must be strings"}) - code = raw_code.strip() - if code not in codes: - codes.append(code) - departments = list( - Department.objects.filter( - organization_id=context.organization_id, - status=DepartmentStatus.ACTIVE, - code__in=codes, - ) - ) - if len(departments) != len(codes): - raise ValidationError({"departmentCodes": "Unknown or disabled department"}) - by_code = {department.code: department for department in departments} - return [by_code[code] for code in codes] - - def _validation_detail(error: ValidationError) -> str: if hasattr(error, "message_dict"): return "; ".join( @@ -129,12 +93,9 @@ def _import_document(*, context: TenantContext, document: dict[str, object]) -> existing = ( Knowledge.objects.select_for_update() .filter(organization_id=context.organization_id, title=title) - .prefetch_related("department_links") .first() ) explicit_category = "categoryPath" in document - explicit_visibility = "visibility" in document - explicit_departments = "departmentCodes" in document category = ( _category_for_path(context=context, raw_path=document["categoryPath"]) if explicit_category @@ -142,28 +103,7 @@ def _import_document(*, context: TenantContext, document: dict[str, object]) -> ) if existing is None: - visibility = _visibility( - document, - default=KnowledgeVisibility.ORGANIZATION, - ) - departments = ( - _departments_for_codes( - context=context, - raw_codes=document["departmentCodes"], - ) - if explicit_departments - else [] - ) - departments_for_scope( - context=context, - visibility=visibility, - department_ids=[department.id for department in departments], - ) - require_knowledge_create( - context=context, - visibility=visibility, - department_ids=[department.id for department in departments], - ) + require_knowledge_create(context=context) create_knowledge( context=context, data=KnowledgeInput( @@ -172,37 +112,12 @@ def _import_document(*, context: TenantContext, document: dict[str, object]) -> content=content, is_enabled=True, category_id=category.id if category is not None else None, - visibility=visibility, - department_ids=tuple(department.id for department in departments), ), ) return "created" - current_department_ids = list(existing.department_links.values_list("department_id", flat=True)) - visibility = _visibility(document, default=existing.visibility) - if explicit_departments: - departments = _departments_for_codes( - context=context, - raw_codes=document["departmentCodes"], - ) - department_ids = [department.id for department in departments] - elif explicit_visibility and visibility == KnowledgeVisibility.ORGANIZATION: - department_ids = [] - else: - department_ids = current_department_ids - if explicit_visibility or explicit_departments: - departments_for_scope( - context=context, - visibility=visibility, - department_ids=department_ids, - ) - if not employee_can_write_knowledge( - context=context, - knowledge=existing, - visibility=visibility, - department_ids=department_ids, - ): - raise PermissionDenied("Knowledge scope is not manageable") + if not employee_can_write_knowledge(context=context, knowledge=existing): + raise PermissionDenied("Knowledge is not manageable") description = _description(document, default=existing.description) category_id = category.id if category is not None else existing.category_id @@ -210,12 +125,9 @@ def _import_document(*, context: TenantContext, document: dict[str, object]) -> existing.description == description and existing.content == content and existing.category_id == category_id - and existing.visibility == visibility - and set(current_department_ids) == set(department_ids) ) if unchanged: return "unchanged" - metadata_scope_explicit = explicit_visibility or explicit_departments update_knowledge( context=context, knowledge=existing, @@ -225,8 +137,6 @@ def _import_document(*, context: TenantContext, document: dict[str, object]) -> content=content, is_enabled=existing.is_enabled, category_id=category.id if explicit_category and category is not None else None, - visibility=visibility if metadata_scope_explicit else None, - department_ids=tuple(department_ids) if metadata_scope_explicit else None, ), ) return "updated" @@ -251,9 +161,6 @@ def import_knowledge_documents( except PermissionDenied as error: result.failed.append({"title": title, "detail": str(error)}) continue - except KnowledgeScopeConflict as error: - result.failed.append({"title": title, "detail": str(error)}) - continue if outcome == "created": result.created += 1 elif outcome == "updated": diff --git a/apps/backend/hub_platform/ai/knowledge_models.py b/apps/backend/hub_platform/ai/knowledge_models.py index 7631105..6eadcbd 100644 --- a/apps/backend/hub_platform/ai/knowledge_models.py +++ b/apps/backend/hub_platform/ai/knowledge_models.py @@ -77,41 +77,3 @@ class KnowledgeCategory(TenantRelationModel): def __str__(self) -> str: return f"category:{self.organization_id}/{self.name}" - - -class KnowledgeDepartment(TenantRelationModel): - tenant_relation_fields = ("knowledge", "department") - - knowledge = models.ForeignKey( - "ai.Knowledge", - on_delete=models.CASCADE, - related_name="department_links", - ) - department = models.ForeignKey( - "identity.Department", - on_delete=models.PROTECT, - related_name="knowledge_links", - ) - - class Meta: - ordering = ["department__name", "department_id"] - constraints = [ - models.UniqueConstraint( - fields=["knowledge", "department"], - name="uniq_knowledge_department", - ) - ] - - def clean(self) -> None: - super().clean() - from hub_platform.identity.models import DepartmentStatus - - if self.department.status != DepartmentStatus.ACTIVE: - raise ValidationError({"department": "Disabled department is not allowed"}) - - def save(self, *args: object, **kwargs: object) -> None: - self.full_clean() - super().save(*args, **kwargs) - - def __str__(self) -> str: - return f"knowledge-department:{self.knowledge_id}/{self.department_id}" diff --git a/apps/backend/hub_platform/ai/knowledge_policy.py b/apps/backend/hub_platform/ai/knowledge_policy.py index 9110224..dd7ab5d 100644 --- a/apps/backend/hub_platform/ai/knowledge_policy.py +++ b/apps/backend/hub_platform/ai/knowledge_policy.py @@ -1,29 +1,24 @@ from __future__ import annotations -from collections.abc import Iterable - from django.core.exceptions import PermissionDenied -from django.db.models import Exists, OuterRef, Q, QuerySet +from django.db.models import QuerySet -from hub_platform.ai.agent_knowledge import knowledge_available_to_channel -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeDepartment -from hub_platform.identity.policy import ( - ResourceScope, - accessible_department_ids, - authorize, -) +from hub_platform.identity.policy import has_capability_any_scope +from hub_platform.ai.models import AIAgent, Knowledge from hub_platform.tenancy.context import TenantContext AI_VIEW = "ai.view" AI_MANAGE = "ai.manage" +# Библиотека знаний — общая для организации (ADR-HUB-0041 §8): областей +# видимости нет, доступ определяется ролью (ai.view/ai.manage у OWNER/ADMIN). -def _department_ids(context: TenantContext, capability: str) -> set[int] | None: + +def _has(context: TenantContext, capability: str) -> bool: membership = context.membership if membership is None or membership.organization_id != context.organization_id: - return set() - return accessible_department_ids(membership, capability) + return False + return has_capability_any_scope(membership, capability) def readable_knowledge( @@ -32,120 +27,48 @@ def readable_knowledge( queryset: QuerySet[Knowledge], capability: str = AI_VIEW, ) -> QuerySet[Knowledge]: - """Apply employee knowledge visibility before detail, aggregation or paging.""" queryset = queryset.filter(organization_id=context.organization_id) - department_ids = _department_ids(context, capability) - if department_ids is None: + if _has(context, capability): return queryset - if not department_ids: - return queryset.none() - return queryset.filter( - Q(visibility=KnowledgeVisibility.ORGANIZATION) - | Q( - visibility=KnowledgeVisibility.DEPARTMENTS, - department_links__department_id__in=department_ids, - ) - ).distinct() + return queryset.none() def writable_knowledge( *, context: TenantContext, queryset: QuerySet[Knowledge] ) -> QuerySet[Knowledge]: - """Return items whose complete current scope is covered by the employee.""" queryset = queryset.filter(organization_id=context.organization_id) - department_ids = _department_ids(context, AI_MANAGE) - if department_ids is None: + if _has(context, AI_MANAGE): return queryset - if not department_ids: - return queryset.none() - covered_links = KnowledgeDepartment.objects.filter( - knowledge_id=OuterRef("pk"), department_id__in=department_ids - ) - uncovered_links = KnowledgeDepartment.objects.filter(knowledge_id=OuterRef("pk")).exclude( - department_id__in=department_ids - ) - return queryset.filter(visibility=KnowledgeVisibility.DEPARTMENTS).filter( - Exists(covered_links), ~Exists(uncovered_links) - ) + return queryset.none() def employee_can_read_knowledge(*, context: TenantContext, knowledge: Knowledge) -> bool: if knowledge.organization_id != context.organization_id: return False - return readable_knowledge( - context=context, - queryset=Knowledge.objects.filter(pk=knowledge.pk), - ).exists() + return _has(context, AI_VIEW) -def employee_can_write_knowledge( - *, - context: TenantContext, - knowledge: Knowledge, - visibility: str | None = None, - department_ids: Iterable[int] | None = None, -) -> bool: - """Require full coverage of both the current and proposed knowledge scope.""" +def employee_can_write_knowledge(*, context: TenantContext, knowledge: Knowledge) -> bool: if knowledge.organization_id != context.organization_id: return False - target_visibility = visibility or knowledge.visibility - target_department_ids = ( - set(department_ids) - if department_ids is not None - else set(knowledge.department_links.values_list("department_id", flat=True)) - ) - allowed_ids = _department_ids(context, AI_MANAGE) - if allowed_ids is None: - return True - if knowledge.visibility == KnowledgeVisibility.ORGANIZATION: - return False - if target_visibility == KnowledgeVisibility.ORGANIZATION: - return False - current_ids = set(knowledge.department_links.values_list("department_id", flat=True)) - return bool(current_ids | target_department_ids) and ( - current_ids | target_department_ids - ).issubset(allowed_ids) - - -def employee_can_create_knowledge( - *, context: TenantContext, visibility: str, department_ids: Iterable[int] -) -> bool: - allowed_ids = _department_ids(context, AI_MANAGE) - if allowed_ids is None: - return True - if visibility != KnowledgeVisibility.DEPARTMENTS: - return False - target_ids = set(department_ids) - return bool(target_ids) and target_ids.issubset(allowed_ids) + return _has(context, AI_MANAGE) def employee_can_manage_categories(*, context: TenantContext) -> bool: - membership = context.membership - return membership is not None and authorize( - membership, - AI_MANAGE, - ResourceScope(organization_id=context.organization_id), - ) + return _has(context, AI_MANAGE) -def require_knowledge_create( - *, context: TenantContext, visibility: str, department_ids: Iterable[int] -) -> None: - if not employee_can_create_knowledge( - context=context, - visibility=visibility, - department_ids=department_ids, - ): - raise PermissionDenied("Knowledge scope is not manageable") +def require_knowledge_create(*, context: TenantContext) -> None: + if not _has(context, AI_MANAGE): + raise PermissionDenied("ai.manage is required") def require_category_manage(*, context: TenantContext) -> None: if not employee_can_manage_categories(context=context): - raise PermissionDenied("Organization-scoped ai.manage is required") + raise PermissionDenied("ai.manage is required") def knowledge_is_available_to_agent(*, knowledge: Knowledge, agent: AIAgent) -> bool: - return knowledge_available_to_channel( - Knowledge.objects.filter(pk=knowledge.pk), - channel=agent.channel, - ).exists() + return ( + knowledge.organization_id == agent.organization_id + ) diff --git a/apps/backend/hub_platform/ai/knowledge_policy_test_base.py b/apps/backend/hub_platform/ai/knowledge_policy_test_base.py index bda6701..13bb505 100644 --- a/apps/backend/hub_platform/ai/knowledge_policy_test_base.py +++ b/apps/backend/hub_platform/ai/knowledge_policy_test_base.py @@ -4,15 +4,8 @@ from hub_platform.ai.knowledge_categories import ( create_category, ensure_uncategorized_category, ) -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.knowledge_visibility import replace_knowledge_visibility from hub_platform.ai.models import Knowledge -from hub_platform.identity.capabilities import ScopeType from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - Department, - EmployeeAccessAssignment, EmployeeRole, HumanUser, Organization, @@ -22,6 +15,9 @@ from hub_platform.tenancy.context import TenantContext class KnowledgePolicyTestBase(TestCase): + """База knowledge-тестов: библиотека общая для организации (ADR-HUB-0041 §8), + доступ ролевой — у EMPLOYEE нет ai.*, у OWNER/ADMIN есть всё.""" + def setUp(self) -> None: self.organization = Organization.objects.create( name="Example", slug="knowledge-policy" @@ -32,77 +28,20 @@ class KnowledgePolicyTestBase(TestCase): ensure_uncategorized_category(self.organization) self.other_category = ensure_uncategorized_category(self.other_organization) self.system_context = TenantContext.for_resource(self.organization) - self.sales = Department.objects.create( - organization=self.organization, code="sales", name="Sales" - ) - self.support = Department.objects.create( - organization=self.organization, code="support", name="Support" - ) self.owner = self._membership("owner@policy.test", EmployeeRole.OWNER) - self.sales_employee = self._membership( - "sales@policy.test", EmployeeRole.EMPLOYEE - ) - self.all_departments_employee = self._membership( - "all@policy.test", EmployeeRole.EMPLOYEE - ) - self.organization_manager = self._membership( - "manager@policy.test", EmployeeRole.EMPLOYEE - ) - self._assign( - self.sales_employee, - "Sales AI", - ("ai.view", "ai.manage"), - self.sales, - ) - self._assign( - self.all_departments_employee, - "Sales AI", - ("ai.view", "ai.manage"), - self.sales, - ) - self._assign( - self.all_departments_employee, - "Support AI", - ("ai.view", "ai.manage"), - self.support, - ) - self._assign( - self.organization_manager, - "Organization AI", - ("ai.view", "ai.manage"), - None, - ) - self.sales_context = TenantContext.for_membership(self.sales_employee) - self.all_departments_context = TenantContext.for_membership( - self.all_departments_employee - ) - self.organization_manager_context = TenantContext.for_membership( - self.organization_manager - ) + self.admin = self._membership("admin@policy.test", EmployeeRole.ADMIN) + self.employee = self._membership("employee@policy.test", EmployeeRole.EMPLOYEE) self.owner_context = TenantContext.for_membership(self.owner) + self.admin_context = TenantContext.for_membership(self.admin) + self.employee_context = TenantContext.for_membership(self.employee) self.products = create_category( context=self.system_context, name="Products", sort_order=10 ) - self.shared = self._knowledge( - "Shared handbook", "Common company rules" - ) - self.sales_only = self._knowledge( - "Sales playbook", "Pricing and qualification" - ) - self.support_only = self._knowledge( - "Support runbook", "Incidents and escalation" - ) - self.multi_department = self._knowledge( - "Customer lifecycle", "Sales to support handoff" - ) - self.disabled_sales = self._knowledge( - "Legacy sales", "Retired script", is_enabled=False - ) - self._scope(self.sales_only, self.sales) - self._scope(self.support_only, self.support) - self._scope(self.multi_department, self.sales, self.support) - self._scope(self.disabled_sales, self.sales) + self.shared = self._knowledge("Shared handbook", "Common company rules") + self.sales_only = self._knowledge("Sales playbook", "Pricing and qualification") + self.support_only = self._knowledge("Support runbook", "Incidents and escalation") + self.disabled = self._knowledge("Legacy script", "Retired", is_enabled=False) def _membership(self, email: str, role: str) -> OrganizationMembership: user = HumanUser.objects.create_user(email=email, password="Password-123") @@ -113,31 +52,6 @@ class KnowledgePolicyTestBase(TestCase): position_title="Specialist", ) - def _assign( - self, - employee: OrganizationMembership, - name: str, - capabilities: tuple[str, ...], - department: Department | None, - ) -> None: - profile = AccessProfile.objects.create( - organization=self.organization, - name=f"{name} {employee.id}", - ) - for capability in capabilities: - AccessProfileCapability.objects.create( - access_profile=profile, capability_code=capability - ) - EmployeeAccessAssignment.objects.create( - employee=employee, - access_profile=profile, - scope_type=( - ScopeType.DEPARTMENT if department else ScopeType.ORGANIZATION - ), - department=department, - assigned_by=self.owner, - ) - def _knowledge( self, title: str, description: str, *, is_enabled: bool = True ) -> Knowledge: @@ -148,12 +62,3 @@ class KnowledgePolicyTestBase(TestCase): description=description, is_enabled=is_enabled, ) - - def _scope(self, knowledge: Knowledge, *departments: Department) -> None: - replace_knowledge_visibility( - context=self.system_context, - knowledge=knowledge, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=[department.id for department in departments], - ) - knowledge.refresh_from_db() diff --git a/apps/backend/hub_platform/ai/knowledge_services.py b/apps/backend/hub_platform/ai/knowledge_services.py index 9279980..cd459e8 100644 --- a/apps/backend/hub_platform/ai/knowledge_services.py +++ b/apps/backend/hub_platform/ai/knowledge_services.py @@ -7,8 +7,6 @@ from django.db import transaction from hub_platform.ai.extraction import extract_text from hub_platform.ai.indexing import reindex_knowledge from hub_platform.ai.knowledge_categories import ensure_uncategorized_category -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.knowledge_visibility import replace_knowledge_visibility from hub_platform.ai.models import ( Knowledge, KnowledgeAttachment, @@ -30,8 +28,6 @@ class KnowledgeInput: content: str is_enabled: bool category_id: int | None = None - visibility: str | None = None - department_ids: tuple[int, ...] | None = None def _knowledge_category(*, context: TenantContext, category_id: int | None) -> KnowledgeCategory: @@ -50,8 +46,6 @@ def _knowledge_category(*, context: TenantContext, category_id: int | None) -> K def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge: if not data.title.strip(): raise ValidationError({"title": "Title is required"}) - visibility = data.visibility or KnowledgeVisibility.ORGANIZATION - department_ids = data.department_ids or () knowledge = Knowledge.objects.create( organization=context.organization, category=_knowledge_category(context=context, category_id=data.category_id), @@ -59,13 +53,6 @@ def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowled description=data.description.strip(), content=data.content, is_enabled=data.is_enabled, - visibility=visibility, - ) - knowledge = replace_knowledge_visibility( - context=context, - knowledge=knowledge, - visibility=visibility, - department_ids=department_ids, ) reindex_knowledge(knowledge) return knowledge @@ -81,17 +68,6 @@ def update_knowledge( raise ValidationError({"title": "Title is required"}) locked = Knowledge.objects.select_for_update().get(pk=knowledge.pk) content_changed = locked.content != data.content - if data.visibility is not None or data.department_ids is not None: - locked = replace_knowledge_visibility( - context=context, - knowledge=locked, - visibility=data.visibility or locked.visibility, - department_ids=( - data.department_ids - if data.department_ids is not None - else tuple(locked.department_links.values_list("department_id", flat=True)) - ), - ) locked.title = data.title.strip() locked.description = data.description.strip() locked.content = data.content diff --git a/apps/backend/hub_platform/ai/knowledge_types.py b/apps/backend/hub_platform/ai/knowledge_types.py index d826510..b83e84d 100644 --- a/apps/backend/hub_platform/ai/knowledge_types.py +++ b/apps/backend/hub_platform/ai/knowledge_types.py @@ -1,9 +1 @@ -from django.db import models - - UNCATEGORIZED_CATEGORY_NAME = "Без категории" - - -class KnowledgeVisibility(models.TextChoices): - ORGANIZATION = "ORGANIZATION", "Organization" - DEPARTMENTS = "DEPARTMENTS", "Departments" diff --git a/apps/backend/hub_platform/ai/knowledge_visibility.py b/apps/backend/hub_platform/ai/knowledge_visibility.py deleted file mode 100644 index cee06fe..0000000 --- a/apps/backend/hub_platform/ai/knowledge_visibility.py +++ /dev/null @@ -1,87 +0,0 @@ -from collections.abc import Iterable - -from django.core.exceptions import ValidationError -from django.db import transaction - -from hub_platform.ai.knowledge_conflicts import require_knowledge_scope_compatible -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.models import Knowledge, KnowledgeDepartment -from hub_platform.identity.models import Department, DepartmentStatus -from hub_platform.tenancy.context import TenantContext - - -def _normalize_department_ids(department_ids: Iterable[int]) -> list[int]: - normalized: list[int] = [] - for department_id in department_ids: - if isinstance(department_id, bool) or not isinstance(department_id, int): - raise ValidationError({"departments": "Department IDs must be integers"}) - if department_id not in normalized: - normalized.append(department_id) - return normalized - - -def departments_for_scope( - *, context: TenantContext, visibility: str, department_ids: Iterable[int] -) -> list[Department]: - if visibility not in KnowledgeVisibility.values: - raise ValidationError({"visibility": "Unknown knowledge visibility"}) - - normalized_ids = _normalize_department_ids(department_ids) - if visibility == KnowledgeVisibility.ORGANIZATION: - if normalized_ids: - raise ValidationError({"departments": "Organization knowledge cannot have departments"}) - return [] - if not normalized_ids: - raise ValidationError( - {"departments": "Department knowledge requires at least one department"} - ) - - departments = list( - Department.objects.select_for_update().filter( - organization_id=context.organization_id, - status=DepartmentStatus.ACTIVE, - id__in=normalized_ids, - ) - ) - if len(departments) != len(normalized_ids): - raise ValidationError({"departments": "Unknown or disabled department"}) - by_id = {department.id: department for department in departments} - return [by_id[department_id] for department_id in normalized_ids] - - -@transaction.atomic -def replace_knowledge_visibility( - *, - context: TenantContext, - knowledge: Knowledge, - visibility: str, - department_ids: Iterable[int], -) -> Knowledge: - if knowledge.organization_id != context.organization_id: - raise ValidationError({"knowledge": "Knowledge belongs to another organization"}) - - locked = Knowledge.objects.select_for_update().get(pk=knowledge.pk) - departments = departments_for_scope( - context=context, - visibility=visibility, - department_ids=department_ids, - ) - require_knowledge_scope_compatible( - knowledge=locked, - visibility=visibility, - department_ids=[department.id for department in departments], - ) - locked.visibility = visibility - locked.save(update_fields=["visibility", "updated_at"]) - locked.department_links.all().delete() - KnowledgeDepartment.objects.bulk_create( - [ - KnowledgeDepartment( - organization=context.organization, - knowledge=locked, - department=department, - ) - for department in departments - ] - ) - return locked diff --git a/apps/backend/hub_platform/ai/migrations/0014_remove_knowledgedepartment_department_and_more.py b/apps/backend/hub_platform/ai/migrations/0014_remove_knowledgedepartment_department_and_more.py new file mode 100644 index 0000000..afb8dd5 --- /dev/null +++ b/apps/backend/hub_platform/ai/migrations/0014_remove_knowledgedepartment_department_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + # Дроп после tenancy-гардов (RLS/триггеры ссылаются на эти таблицы). + ('tenancy', '0017_drop_commerce'), + ('ai', '0013_portal_article_knowledge'), + ] + + operations = [ + # Constraint снимается до полей: иначе state хранит constraint на + # несуществующее поле и обратный DeleteModel падает при create_model. + migrations.RemoveConstraint( + model_name='knowledgedepartment', + name='uniq_knowledge_department', + ), + migrations.RemoveField( + model_name='knowledgedepartment', + name='department', + ), + migrations.RemoveField( + model_name='knowledgedepartment', + name='knowledge', + ), + migrations.RemoveField( + model_name='knowledgedepartment', + name='organization', + ), + migrations.RemoveField( + model_name='knowledge', + name='departments', + ), + migrations.RemoveConstraint( + model_name='knowledge', + name='knowledge_visibility_valid', + ), + migrations.RemoveField( + model_name='knowledge', + name='visibility', + ), + migrations.DeleteModel( + name='KnowledgeDepartment', + ), + ] diff --git a/apps/backend/hub_platform/ai/models.py b/apps/backend/hub_platform/ai/models.py index d633d30..74f09cc 100644 --- a/apps/backend/hub_platform/ai/models.py +++ b/apps/backend/hub_platform/ai/models.py @@ -4,7 +4,6 @@ from django.core.exceptions import ValidationError from django.db import models from pgvector.django import VectorField -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.tenancy.models import TenantRelationModel # Один основной агент на канал обработки (ADR-HUB-0019, ADR-HUB-0023). @@ -26,7 +25,8 @@ class CredentialMode(models.TextChoices): BYOK = "BYOK", "BYOK" # секрет организации через AIAgent.provider_integration -# --- Знания: иерархия и отделовая доступность (ADR-HUB-0036) --- +# --- Знания: общая библиотека организации с иерархией категорий +# (ADR-HUB-0023, ADR-HUB-0041 §8: областей видимости по отделам нет) --- class Knowledge(models.Model): @@ -40,36 +40,17 @@ class Knowledge(models.Model): description = models.CharField(max_length=500, blank=True) content = models.TextField(blank=True) # Markdown is_enabled = models.BooleanField(default=True) - visibility = models.CharField( - max_length=16, - choices=KnowledgeVisibility.choices, - default=KnowledgeVisibility.ORGANIZATION, - ) - departments = models.ManyToManyField( - "identity.Department", - through="KnowledgeDepartment", - related_name="knowledge_items", - blank=True, - ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["title"] verbose_name_plural = "knowledge" - constraints = [ - models.CheckConstraint( - condition=models.Q(visibility__in=KnowledgeVisibility.values), - name="knowledge_visibility_valid", - ) - ] def clean(self) -> None: super().clean() if self.category_id is not None and self.category.organization_id != self.organization_id: raise ValidationError({"category": "Category belongs to another organization"}) - if self.visibility not in KnowledgeVisibility.values: - raise ValidationError({"visibility": "Unknown knowledge visibility"}) def save(self, *args: object, **kwargs: object) -> None: self.clean() @@ -83,7 +64,6 @@ class Knowledge(models.Model): # models after Knowledge exists so they are registered without growing this file. from hub_platform.ai.knowledge_models import ( # noqa: E402, F401 KnowledgeCategory, - KnowledgeDepartment, ) diff --git a/apps/backend/hub_platform/ai/selectors.py b/apps/backend/hub_platform/ai/selectors.py index b1410ae..5a82954 100644 --- a/apps/backend/hub_platform/ai/selectors.py +++ b/apps/backend/hub_platform/ai/selectors.py @@ -4,17 +4,15 @@ from django.db.models import Count, Prefetch, Q, QuerySet from hub_platform.ai.agent_knowledge import knowledge_available_to_channel from hub_platform.ai.knowledge_policy import readable_knowledge, writable_knowledge -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeCategory from hub_platform.channels.models import Channel -from hub_platform.identity.models import Department -from hub_platform.identity.policy import accessible_department_ids +from hub_platform.identity.policy import has_capability_any_scope from hub_platform.tenancy.context import TenantContext def agents_for_context(context: TenantContext) -> QuerySet[AIAgent]: return ( - AIAgent.objects.select_related("channel", "channel__department", "channel__product") + AIAgent.objects.select_related("channel", "channel__group", "channel__product") .prefetch_related( "knowledge_items", "portal_articles__portal", @@ -31,12 +29,9 @@ def agent_for_context(*, context: TenantContext, agent_id: int) -> AIAgent: def agents_for_employee(*, context: TenantContext, capability: str) -> QuerySet[AIAgent]: queryset = agents_for_context(context) - department_ids = accessible_department_ids(context.membership, capability) - if department_ids is None: + if has_capability_any_scope(context.membership, capability): return queryset - if not department_ids: - return queryset.none() - return queryset.filter(channel__department_id__in=department_ids) + return queryset.none() def agent_for_employee(*, context: TenantContext, agent_id: int, capability: str) -> AIAgent: @@ -50,12 +45,9 @@ def channel_for_ai_capability( organization_id=context.organization_id, code=channel_code, ) - department_ids = accessible_department_ids(context.membership, capability) - if department_ids is None: + if has_capability_any_scope(context.membership, capability): return queryset.get() - if not department_ids: - return queryset.none().get() - return queryset.filter(department_id__in=department_ids).get() + return queryset.none().get() def knowledge_for_context(context: TenantContext) -> QuerySet[Knowledge]: @@ -69,13 +61,7 @@ def _knowledge_base(context: TenantContext) -> QuerySet[Knowledge]: def _with_knowledge_relations(queryset: QuerySet[Knowledge]) -> QuerySet[Knowledge]: return ( queryset.select_related("category") - .prefetch_related( - "attachments", - Prefetch( - "departments", - queryset=Department.objects.order_by("name", "id"), - ), - ) + .prefetch_related("attachments") .annotate( agents_count=Count("agents", distinct=True), fragments_count=Count("fragments", distinct=True), @@ -163,8 +149,6 @@ def category_tree_for_employee(*, context: TenantContext) -> list[KnowledgeCateg @dataclass(frozen=True, slots=True) class KnowledgeFilters: category_id: int | None = None - department_id: int | None = None - visibility: str | None = None is_enabled: bool | None = None search: str = "" @@ -174,13 +158,6 @@ def apply_knowledge_filters( ) -> QuerySet[Knowledge]: if filters.category_id is not None: queryset = queryset.filter(category_id=filters.category_id) - if filters.department_id is not None: - queryset = queryset.filter( - visibility=KnowledgeVisibility.DEPARTMENTS, - department_links__department_id=filters.department_id, - ) - if filters.visibility is not None: - queryset = queryset.filter(visibility=filters.visibility) if filters.is_enabled is not None: queryset = queryset.filter(is_enabled=filters.is_enabled) search = filters.search.strip() diff --git a/apps/backend/hub_platform/ai/serializers.py b/apps/backend/hub_platform/ai/serializers.py index 9b12565..5a0588a 100644 --- a/apps/backend/hub_platform/ai/serializers.py +++ b/apps/backend/hub_platform/ai/serializers.py @@ -4,7 +4,6 @@ from hub_platform.ai.models import ( KnowledgeAttachment, KnowledgeCategory, ) -from hub_platform.identity.models import Department from hub_platform.support_portals.addressing import article_public_url @@ -14,7 +13,11 @@ def _channel_ref(channel) -> dict[str, object]: "code": channel.code, "name": channel.name, "product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None, - "department": department_ref_payload(channel.department) if channel.department_id else None, + "group": ( + {"id": channel.group_id, "name": channel.group.name} + if channel.group_id + else None + ), } @@ -47,25 +50,12 @@ def category_payload(category: KnowledgeCategory) -> dict[str, object]: } -def department_ref_payload(department: Department) -> dict[str, object]: - return { - "id": department.id, - "code": department.code, - "name": department.name, - } - - def knowledge_payload(knowledge: Knowledge, *, include_content: bool = True) -> dict[str, object]: payload: dict[str, object] = { "id": knowledge.id, "title": knowledge.title, "description": knowledge.description, "category": category_ref_payload(knowledge.category), - "visibility": knowledge.visibility, - "departments": [ - department_ref_payload(department) - for department in knowledge.departments.all() - ], "isEnabled": knowledge.is_enabled, "attachments": [attachment_payload(attachment) for attachment in knowledge.attachments.all()], "agentsCount": getattr(knowledge, "agents_count", None), diff --git a/apps/backend/hub_platform/ai/test_agent_attachments.py b/apps/backend/hub_platform/ai/test_agent_attachments.py index 7390b8c..897a1a1 100644 --- a/apps/backend/hub_platform/ai/test_agent_attachments.py +++ b/apps/backend/hub_platform/ai/test_agent_attachments.py @@ -5,7 +5,6 @@ from django.test import TestCase from hub_platform.ai.agent_knowledge import runtime_portal_articles_for_agent from hub_platform.ai.knowledge_categories import create_category from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.models import AIAgent, AIAgentStatus, KnowledgeFragment from hub_platform.ai.retrieval import lexical_search from hub_platform.channels.models import Channel @@ -32,42 +31,23 @@ class AgentAttachmentTestCase(TestCase): password="temporary-password", ) self.organization = result.organization - self.sales = result.sales_department - self.support = result.support_department self.context = system_tenant_context(self.organization) self.category = create_category(context=self.context, name="Library") self.support_channel = Channel.objects.create( organization=self.organization, code="attach-support", name="Attach support", - department=self.support, - ) - self.sales_channel = Channel.objects.create( - organization=self.organization, - code="attach-sales", - name="Attach sales", - department=self.sales, + group=result.support_group, ) self.support_agent = AIAgent.objects.create( channel=self.support_channel, name="Support agent", status=AIAgentStatus.ACTIVE, ) - self.sales_agent = AIAgent.objects.create( - channel=self.sales_channel, - name="Sales agent", - status=AIAgentStatus.ACTIVE, - ) self.client = TenantAPIClient() self.client.force_authenticate(result.owner) - def knowledge( - self, - title: str, - *, - visibility: str = KnowledgeVisibility.ORGANIZATION, - department_ids: tuple[int, ...] = (), - ): + def knowledge(self, title: str): return create_knowledge( context=self.context, data=KnowledgeInput( @@ -76,8 +56,6 @@ class AgentAttachmentTestCase(TestCase): content=f"{title} content", is_enabled=True, category_id=self.category.id, - visibility=visibility, - department_ids=department_ids, ), ) @@ -118,30 +96,26 @@ class AgentAttachmentTestCase(TestCase): class AgentKnowledgeLinkApiTests(AgentAttachmentTestCase): - def test_attach_adds_available_knowledge_and_reports_skipped(self) -> None: + def test_attach_adds_all_selected_organization_knowledge(self) -> None: shared = self.knowledge("Shared") - sales_only = self.knowledge( - "Sales only", - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=(self.sales.id,), - ) + second = self.knowledge("Second") response = self.post( "/api/v1/ai/knowledge/bulk/agent/", { "agentId": self.support_agent.id, - "knowledgeIds": [shared.id, sales_only.id], + "knowledgeIds": [shared.id, second.id], "action": "attach", }, ) self.assertEqual(response.status_code, 200) payload = response.json() - self.assertEqual(payload["changedIds"], [shared.id]) - self.assertEqual(payload["skippedIds"], [sales_only.id]) + self.assertEqual(payload["changedIds"], sorted([shared.id, second.id])) + self.assertEqual(payload["skippedIds"], []) self.assertEqual( set(self.support_agent.knowledge_items.values_list("id", flat=True)), - {shared.id}, + {shared.id, second.id}, ) def test_attach_is_idempotent_for_already_linked_knowledge(self) -> None: @@ -157,25 +131,21 @@ class AgentKnowledgeLinkApiTests(AgentAttachmentTestCase): self.assertEqual(response.json()["changedIds"], []) self.assertEqual(self.support_agent.knowledge_items.count(), 1) - def test_detach_removes_link_without_availability_check(self) -> None: - scoped = self.knowledge( - "Support scoped", - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=(self.support.id,), - ) - self.support_agent.knowledge_items.add(scoped) + def test_detach_removes_link(self) -> None: + shared = self.knowledge("Shared") + self.support_agent.knowledge_items.add(shared) response = self.post( "/api/v1/ai/knowledge/bulk/agent/", { "agentId": self.support_agent.id, - "knowledgeIds": [scoped.id], + "knowledgeIds": [shared.id], "action": "detach", }, ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["changedIds"], [scoped.id]) + self.assertEqual(response.json()["changedIds"], [shared.id]) self.assertFalse(self.support_agent.knowledge_items.exists()) def test_unknown_knowledge_id_rejects_whole_request(self) -> None: @@ -201,7 +171,7 @@ class AgentKnowledgeLinkApiTests(AgentAttachmentTestCase): class AgentPortalArticleLinkApiTests(AgentAttachmentTestCase): - def test_attach_article_to_support_agent(self) -> None: + def test_attach_article_to_agent(self) -> None: article = self.article(self.portal(), title="Refund policy") response = self.post( @@ -210,26 +180,14 @@ class AgentPortalArticleLinkApiTests(AgentAttachmentTestCase): ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["changedIds"], [article.id]) + payload = response.json() + self.assertEqual(payload["changedIds"], [article.id]) + self.assertEqual(payload["skippedIds"], []) self.assertEqual( set(self.support_agent.portal_articles.values_list("id", flat=True)), {article.id}, ) - def test_article_is_skipped_for_agent_outside_support_department(self) -> None: - article = self.article(self.portal(), title="Refund policy") - - response = self.post( - "/api/v1/ai/portal-articles/bulk/agent/", - {"agentId": self.sales_agent.id, "articleIds": [article.id]}, - ) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["changedIds"], []) - self.assertEqual(payload["skippedIds"], [article.id]) - self.assertFalse(self.sales_agent.portal_articles.exists()) - def test_detach_article_keeps_other_links(self) -> None: portal = self.portal() first = self.article(portal, title="First") diff --git a/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py b/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py index dab9ebf..73a5e3c 100644 --- a/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py +++ b/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py @@ -2,13 +2,15 @@ import json from django.test import TestCase +from hub_platform.ai.knowledge_categories import ensure_uncategorized_category from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.models import AIAgent, AIAgentStatus from hub_platform.ai.retrieval import lexical_search, semantic_search from hub_platform.ai.runtime import knowledge_catalog from hub_platform.channels.models import Channel from hub_platform.identity.bootstrap import bootstrap_edevs_owner +from hub_platform.identity.models import Organization +from hub_platform.tenancy.context import TenantContext from hub_platform.testing import TenantAPIClient, system_tenant_context @@ -19,8 +21,6 @@ class AgentKnowledgeAssignmentTests(TestCase): password="temporary-password", ) self.organization = result.organization - self.sales = result.sales_department - self.support = result.support_department self.context = system_tenant_context(self.organization) self.shared = create_knowledge( context=self.context, @@ -31,33 +31,19 @@ class AgentKnowledgeAssignmentTests(TestCase): is_enabled=True, ), ) - self.support_only = create_knowledge( + self.second = create_knowledge( context=self.context, data=KnowledgeInput( - title="Support only", + title="Second", description="", - content="Support procedure", + content="Second procedure", is_enabled=True, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=(self.support.id,), ), ) - self.sales_channel = Channel.objects.create( + self.channel = Channel.objects.create( organization=self.organization, - code="sales-agent", - name="Sales", - department=self.sales, - ) - self.support_channel = Channel.objects.create( - organization=self.organization, - code="support-agent", - name="Support", - department=self.support, - ) - self.no_department_channel = Channel.objects.create( - organization=self.organization, - code="company-agent", - name="Company", + code="org-agent", + name="Org", ) self.client = TenantAPIClient() self.client.login( @@ -72,44 +58,31 @@ class AgentKnowledgeAssignmentTests(TestCase): content_type="application/json", ) - def test_create_rejects_entire_mixed_department_selection(self) -> None: + def test_create_accepts_organization_knowledge(self) -> None: response = self._create( - self.sales_channel, - [self.shared.id, self.support_only.id], - ) - - self.assertEqual(response.status_code, 400) - self.assertFalse(AIAgent.objects.filter(channel=self.sales_channel).exists()) - - def test_support_agent_accepts_support_and_organization_knowledge(self) -> None: - response = self._create( - self.support_channel, - [self.shared.id, self.support_only.id], + self.channel, + [self.shared.id, self.second.id], ) self.assertEqual(response.status_code, 201) - agent = AIAgent.objects.get(channel=self.support_channel) + agent = AIAgent.objects.get(channel=self.channel) self.assertEqual( set(agent.knowledge_items.values_list("id", flat=True)), - {self.shared.id, self.support_only.id}, + {self.shared.id, self.second.id}, ) - def test_channel_without_department_accepts_only_organization_knowledge( - self, - ) -> None: - denied = self._create( - self.no_department_channel, - [self.support_only.id], + def test_create_rejects_selection_with_unknown_knowledge_id(self) -> None: + response = self._create( + self.channel, + [self.shared.id, 999999], ) - self.assertEqual(denied.status_code, 400) - self.assertFalse(AIAgent.objects.filter(channel=self.no_department_channel).exists()) - allowed = self._create(self.no_department_channel, [self.shared.id]) - self.assertEqual(allowed.status_code, 201) + self.assertEqual(response.status_code, 400) + self.assertFalse(AIAgent.objects.filter(channel=self.channel).exists()) def test_update_rolls_back_agent_fields_and_selection_on_invalid_id(self) -> None: agent = AIAgent.objects.create( - channel=self.sales_channel, + channel=self.channel, name="Original", status=AIAgentStatus.ACTIVE, ) @@ -120,7 +93,7 @@ class AgentKnowledgeAssignmentTests(TestCase): data=json.dumps( { "name": "Changed", - "knowledgeIds": [self.shared.id, self.support_only.id], + "knowledgeIds": [self.shared.id, 999999], } ), content_type="application/json", @@ -151,51 +124,60 @@ class AgentKnowledgeRuntimeDefenseTests(TestCase): is_enabled=True, ), ) - self.support_only = create_knowledge( - context=self.context, + self.other_organization = Organization.objects.create( + name="Foreign", + slug="runtime-foreign", + ) + ensure_uncategorized_category(self.other_organization) + self.foreign = create_knowledge( + context=TenantContext.for_resource(self.other_organization), data=KnowledgeInput( - title="Support secret", + title="Foreign secret", description="Must stay hidden", - content="secret support phrase", + content="secret foreign phrase", is_enabled=True, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=(result.support_department.id,), ), ) channel = Channel.objects.create( organization=result.organization, code="runtime-sales", name="Runtime sales", - department=result.sales_department, ) self.agent = AIAgent.objects.create( channel=channel, name="Runtime agent", status=AIAgentStatus.ACTIVE, ) - # Simulate a historical/direct-DB invalid assignment. - self.agent.knowledge_items.add(self.shared, self.support_only) + self.agent.knowledge_items.add(self.shared) - def test_catalog_excludes_incompatible_selected_knowledge(self) -> None: + def test_cross_organization_assignment_is_rejected_by_database(self) -> None: + # Парный триггер enforce_tenant_pair не даёт «протащить» чужое знание + # даже прямой записью в M2M-таблицу. + from django.db import DatabaseError, transaction + + with self.assertRaises(DatabaseError), transaction.atomic(): + self.agent.knowledge_items.add(self.foreign) + + def test_catalog_excludes_foreign_organization_knowledge(self) -> None: catalog = knowledge_catalog(self.agent) self.assertIn(self.shared.title, catalog) - self.assertNotIn(self.support_only.title, catalog) + self.assertNotIn(self.foreign.title, catalog) - def test_lexical_retrieval_excludes_incompatible_fragment(self) -> None: - results = lexical_search(self.agent, "secret support", limit=10) + def test_lexical_retrieval_excludes_foreign_fragment(self) -> None: + results = lexical_search(self.agent, "secret foreign", limit=10) self.assertNotIn( - self.support_only.id, + self.foreign.id, {fragment.knowledge_id for fragment in results}, ) - def test_semantic_retrieval_excludes_incompatible_fragment(self) -> None: - query_vector = list(self.support_only.fragments.first().embedding) + def test_semantic_retrieval_excludes_foreign_fragment(self) -> None: + query_vector = list(self.foreign.fragments.first().embedding) results = semantic_search(self.agent, query_vector, limit=10) self.assertNotIn( - self.support_only.id, + self.foreign.id, {fragment.knowledge_id for fragment in results}, ) diff --git a/apps/backend/hub_platform/ai/test_knowledge_api_v2.py b/apps/backend/hub_platform/ai/test_knowledge_api_v2.py index 4dfe887..aa127ab 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_api_v2.py +++ b/apps/backend/hub_platform/ai/test_knowledge_api_v2.py @@ -2,9 +2,6 @@ import json from hub_platform.ai.knowledge_categories import create_category from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.models import Knowledge -from hub_platform.identity.models import Department, DepartmentStatus from hub_platform.subscriptions.testing import create_test_subscription from hub_platform.testing import TenantAPIClient @@ -14,18 +11,12 @@ class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase): super().setUp() create_test_subscription(self.organization) self.client = TenantAPIClient() - self.client.force_authenticate(self.organization_manager.user) + self.client.force_authenticate(self.admin.user) self.guides = create_category( context=self.system_context, parent=self.products, name="Guides", ) - self.disabled_department = Department.objects.create( - organization=self.organization, - code="disabled-api", - name="Disabled", - status=DepartmentStatus.DISABLED, - ) def _post(self, payload: dict[str, object]): return self.client.post( @@ -34,15 +25,13 @@ class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase): content_type="application/json", ) - def test_create_and_detail_return_complete_scope_payload(self) -> None: + def test_create_and_detail_return_category_payload(self) -> None: response = self._post( { - "title": "Scoped guide", - "description": "For two teams", + "title": "Team guide", + "description": "For everyone", "content": "Guide content", "categoryId": self.guides.id, - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentIds": [self.support.id, self.sales.id], } ) @@ -52,15 +41,12 @@ class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase): payload["category"], {"id": self.guides.id, "name": "Guides", "parentId": self.products.id}, ) - self.assertEqual(payload["visibility"], KnowledgeVisibility.DEPARTMENTS) - self.assertEqual( - [department["id"] for department in payload["departments"]], - [self.sales.id, self.support.id], - ) + self.assertNotIn("visibility", payload) + self.assertNotIn("departments", payload) detail = self.client.get(f"/api/v1/ai/knowledge/{payload['id']}/") self.assertEqual(detail.json()["knowledge"]["content"], "Guide content") - def test_update_scope_category_and_metadata_is_atomic(self) -> None: + def test_update_category_and_metadata_is_atomic(self) -> None: knowledge = self.sales_only response = self.client.patch( f"/api/v1/ai/knowledge/{knowledge.id}/", @@ -68,8 +54,6 @@ class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase): { "title": "Moved playbook", "category": {"id": self.guides.id}, - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departments": [{"id": self.sales.id}, {"id": self.support.id}], } ), content_type="application/json", @@ -77,17 +61,13 @@ class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase): self.assertEqual(response.status_code, 200) payload = response.json()["knowledge"] self.assertEqual(payload["category"]["id"], self.guides.id) - self.assertEqual( - {item["id"] for item in payload["departments"]}, - {self.sales.id, self.support.id}, - ) invalid = self.client.patch( f"/api/v1/ai/knowledge/{knowledge.id}/", data=json.dumps( { "title": "Must roll back", - "departmentIds": [self.disabled_department.id], + "categoryId": 999999, } ), content_type="application/json", @@ -95,57 +75,14 @@ class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase): self.assertEqual(invalid.status_code, 400) knowledge.refresh_from_db() self.assertEqual(knowledge.title, "Moved playbook") - self.assertEqual( - set(knowledge.departments.values_list("id", flat=True)), - {self.sales.id, self.support.id}, - ) + self.assertEqual(knowledge.category_id, self.guides.id) - organization_scope = self.client.patch( - f"/api/v1/ai/knowledge/{knowledge.id}/", - data=json.dumps({"visibility": KnowledgeVisibility.ORGANIZATION}), - content_type="application/json", - ) - self.assertEqual(organization_scope.status_code, 200) - self.assertEqual( - organization_scope.json()["knowledge"]["departments"], [] - ) - - def test_department_manager_can_create_own_scope_but_cannot_expand_it(self) -> None: - self.client.force_authenticate(self.sales_employee.user) - created = self._post( - { - "title": "Sales only API", - "categoryId": self.guides.id, - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentIds": [self.sales.id], - } - ) - self.assertEqual(created.status_code, 201) - knowledge_id = created.json()["knowledge"]["id"] - - denied = self.client.patch( - f"/api/v1/ai/knowledge/{knowledge_id}/", - data=json.dumps({"departmentIds": [self.sales.id, self.support.id]}), - content_type="application/json", - ) - self.assertEqual(denied.status_code, 403) - self.assertEqual( - set( - Knowledge.objects.get(id=knowledge_id).departments.values_list( - "id", flat=True - ) - ), - {self.sales.id}, - ) - - def test_list_filters_category_department_visibility_and_status(self) -> None: + def test_list_filters_category_status_and_search(self) -> None: created = self._post( { "title": "Disabled filtered guide", "description": "Unique filter phrase", "categoryId": self.guides.id, - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentIds": [self.sales.id], "isEnabled": False, } ).json()["knowledge"] @@ -153,8 +90,6 @@ class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase): "/api/v1/ai/knowledge/", { "category": self.guides.id, - "department": self.sales.id, - "visibility": KnowledgeVisibility.DEPARTMENTS, "isEnabled": "false", "search": "unique filter", }, @@ -164,7 +99,5 @@ class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase): [item["id"] for item in response.json()["items"]], [created["id"]] ) - invalid = self.client.get( - "/api/v1/ai/knowledge/", {"visibility": "PUBLIC"} - ) + invalid = self.client.get("/api/v1/ai/knowledge/", {"isEnabled": "banana"}) self.assertEqual(invalid.status_code, 400) diff --git a/apps/backend/hub_platform/ai/test_knowledge_bulk.py b/apps/backend/hub_platform/ai/test_knowledge_bulk.py index 5ecc00c..2127795 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_bulk.py +++ b/apps/backend/hub_platform/ai/test_knowledge_bulk.py @@ -4,7 +4,6 @@ from django.test import TestCase from hub_platform.ai.knowledge_categories import create_category from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.models import AIAgent, AIAgentStatus from hub_platform.channels.models import Channel from hub_platform.identity.bootstrap import bootstrap_edevs_owner @@ -18,8 +17,6 @@ class KnowledgeBulkApiTests(TestCase): password="temporary-password", ) self.organization = result.organization - self.sales = result.sales_department - self.support = result.support_department self.context = system_tenant_context(self.organization) self.source = create_category( context=self.context, @@ -44,8 +41,6 @@ class KnowledgeBulkApiTests(TestCase): title: str, *, category_id: int | None = None, - visibility: str = KnowledgeVisibility.ORGANIZATION, - department_ids: tuple[int, ...] = (), is_enabled: bool = True, ): return create_knowledge( @@ -56,8 +51,6 @@ class KnowledgeBulkApiTests(TestCase): content=f"{title} content", is_enabled=is_enabled, category_id=category_id or self.source.id, - visibility=visibility, - department_ids=department_ids, ), ) @@ -106,63 +99,6 @@ class KnowledgeBulkApiTests(TestCase): {self.first.id, self.second.id}, ) - def test_bulk_visibility_conflict_rolls_back_every_item(self) -> None: - channel = Channel.objects.create( - organization=self.organization, - code="bulk-sales", - name="Bulk sales", - department=self.sales, - ) - agent = AIAgent.objects.create( - channel=channel, - name="Bulk sales agent", - status=AIAgentStatus.ACTIVE, - ) - agent.knowledge_items.add(self.first) - - response = self._post( - "/api/v1/ai/knowledge/bulk/visibility/", - { - "knowledgeIds": [self.first.id, self.second.id], - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentIds": [self.support.id], - }, - ) - - self.assertEqual(response.status_code, 409) - for knowledge in (self.first, self.second): - knowledge.refresh_from_db() - self.assertEqual(knowledge.visibility, KnowledgeVisibility.ORGANIZATION) - self.assertFalse(knowledge.department_links.exists()) - - def test_bulk_visibility_replaces_all_links_without_reindexing(self) -> None: - fragment_ids = { - self.first.id: list(self.first.fragments.values_list("id", flat=True)), - self.second.id: list(self.second.fragments.values_list("id", flat=True)), - } - - response = self._post( - "/api/v1/ai/knowledge/bulk/visibility/", - { - "knowledgeIds": [self.first.id, self.second.id], - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentIds": [self.sales.id, self.support.id], - }, - ) - - self.assertEqual(response.status_code, 200) - for knowledge in (self.first, self.second): - knowledge.refresh_from_db() - self.assertEqual(knowledge.visibility, KnowledgeVisibility.DEPARTMENTS) - self.assertEqual( - set(knowledge.department_links.values_list("department_id", flat=True)), - {self.sales.id, self.support.id}, - ) - self.assertEqual( - list(knowledge.fragments.values_list("id", flat=True)), - fragment_ids[knowledge.id], - ) - class AgentCategoryKnowledgeSelectionTests(TestCase): def setUp(self) -> None: @@ -180,7 +116,6 @@ class AgentCategoryKnowledgeSelectionTests(TestCase): organization=self.organization, code="category-sales", name="Category sales", - department=result.sales_department, ) self.agent = AIAgent.objects.create( channel=self.channel, @@ -195,12 +130,6 @@ class AgentCategoryKnowledgeSelectionTests(TestCase): category_id=self.category.id, is_enabled=False, ) - self.support_only = self._knowledge( - "Support", - category_id=self.category.id, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=(result.support_department.id,), - ) self.client = TenantAPIClient() self.client.login( username="owner@edevs.tech", @@ -212,8 +141,6 @@ class AgentCategoryKnowledgeSelectionTests(TestCase): title: str, *, category_id: int | None = None, - visibility: str = KnowledgeVisibility.ORGANIZATION, - department_ids: tuple[int, ...] = (), is_enabled: bool = True, ): return create_knowledge( @@ -224,12 +151,10 @@ class AgentCategoryKnowledgeSelectionTests(TestCase): content=title, is_enabled=is_enabled, category_id=category_id, - visibility=visibility, - department_ids=department_ids, ), ) - def test_selection_adds_only_current_compatible_category_items(self) -> None: + def test_selection_adds_only_current_category_items(self) -> None: response = self.client.post( f"/api/v1/ai/agents/{self.agent.id}/knowledge/select-category/", data=json.dumps({"categoryId": self.category.id}), diff --git a/apps/backend/hub_platform/ai/test_knowledge_bulk_policy.py b/apps/backend/hub_platform/ai/test_knowledge_bulk_policy.py deleted file mode 100644 index c550bdb..0000000 --- a/apps/backend/hub_platform/ai/test_knowledge_bulk_policy.py +++ /dev/null @@ -1,64 +0,0 @@ -import json - -from hub_platform.ai.knowledge_categories import create_category -from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.testing import TenantAPIClient - - -class KnowledgeBulkPolicyTests(KnowledgePolicyTestBase): - def setUp(self) -> None: - super().setUp() - from hub_platform.subscriptions.testing import create_test_subscription - - create_test_subscription(self.organization) - self.target = create_category( - context=self.system_context, - name="Bulk target", - ) - self.client = TenantAPIClient() - self.client.force_login(self.sales_employee.user) - - def _post(self, path: str, payload: dict[str, object]): - return self.client.post( - path, - data=json.dumps(payload), - content_type="application/json", - ) - - def test_mixed_scope_move_is_denied_and_atomic(self) -> None: - response = self._post( - "/api/v1/ai/knowledge/bulk/move/", - { - "knowledgeIds": [self.sales_only.id, self.support_only.id], - "categoryId": self.target.id, - }, - ) - - self.assertEqual(response.status_code, 403) - self.sales_only.refresh_from_db() - self.support_only.refresh_from_db() - self.assertEqual(self.sales_only.category_id, self.products.id) - self.assertEqual(self.support_only.category_id, self.products.id) - - def test_mixed_scope_visibility_change_is_denied_and_atomic(self) -> None: - response = self._post( - "/api/v1/ai/knowledge/bulk/visibility/", - { - "knowledgeIds": [self.sales_only.id, self.support_only.id], - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentIds": [self.sales.id], - }, - ) - - self.assertEqual(response.status_code, 403) - self.sales_only.refresh_from_db() - self.support_only.refresh_from_db() - self.assertEqual( - set(self.sales_only.departments.values_list("id", flat=True)), - {self.sales.id}, - ) - self.assertEqual( - set(self.support_only.departments.values_list("id", flat=True)), - {self.support.id}, - ) diff --git a/apps/backend/hub_platform/ai/test_knowledge_category_api.py b/apps/backend/hub_platform/ai/test_knowledge_category_api.py index 560c182..8428baf 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_category_api.py +++ b/apps/backend/hub_platform/ai/test_knowledge_category_api.py @@ -11,7 +11,7 @@ class KnowledgeCategoryApiTests(KnowledgePolicyTestBase): super().setUp() create_test_subscription(self.organization) self.client = TenantAPIClient() - self.client.force_authenticate(self.organization_manager.user) + self.client.force_authenticate(self.admin.user) def _create( self, name: str, *, parent_id: int | None = None, sort_order: int = 0 @@ -35,16 +35,11 @@ class KnowledgeCategoryApiTests(KnowledgePolicyTestBase): ids = [item["id"] for item in items] self.assertLess(ids.index(self.products.id), ids.index(child["id"])) products = next(item for item in items if item["id"] == self.products.id) - self.assertEqual(products["knowledgeCount"], 5) - - self.client.force_authenticate(self.sales_employee.user) - restricted = self.client.get("/api/v1/ai/knowledge/categories/") - products = next( - item - for item in restricted.json()["items"] - if item["id"] == self.products.id - ) self.assertEqual(products["knowledgeCount"], 4) + + self.client.force_authenticate(self.employee.user) + restricted = self.client.get("/api/v1/ai/knowledge/categories/") + self.assertEqual(restricted.status_code, 403) denied = self._create("Forbidden") self.assertEqual(denied.status_code, 403) diff --git a/apps/backend/hub_platform/ai/test_knowledge_import_v2.py b/apps/backend/hub_platform/ai/test_knowledge_import_v2.py index 1a864f5..a9636ec 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_import_v2.py +++ b/apps/backend/hub_platform/ai/test_knowledge_import_v2.py @@ -4,14 +4,11 @@ from django.test import TestCase from hub_platform.ai.knowledge_categories import create_category from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase -from hub_platform.ai.knowledge_types import ( - UNCATEGORIZED_CATEGORY_NAME, - KnowledgeVisibility, -) +from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME from hub_platform.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeCategory from hub_platform.channels.models import Channel from hub_platform.identity.bootstrap import bootstrap_edevs_owner -from hub_platform.identity.models import Department, DepartmentStatus +from hub_platform.subscriptions.testing import create_test_subscription from hub_platform.testing import TenantAPIClient, system_tenant_context @@ -22,8 +19,6 @@ class KnowledgeMetadataImportTests(TestCase): password="temporary-password", ) self.organization = result.organization - self.sales = result.sales_department - self.support = result.support_department self.context = system_tenant_context(self.organization) self.products = create_category( context=self.context, @@ -47,25 +42,21 @@ class KnowledgeMetadataImportTests(TestCase): content_type="application/json", ) - def test_legacy_document_uses_uncategorized_organization_defaults(self) -> None: + def test_legacy_document_uses_uncategorized_default(self) -> None: response = self._import([{"title": "Legacy", "content": "Legacy text"}]) self.assertEqual(response.status_code, 201) self.assertEqual(response.json()["created"], 1) knowledge = Knowledge.objects.get(title="Legacy") self.assertEqual(knowledge.category.name, UNCATEGORIZED_CATEGORY_NAME) - self.assertEqual(knowledge.visibility, KnowledgeVisibility.ORGANIZATION) - self.assertFalse(knowledge.department_links.exists()) - def test_explicit_category_and_department_scope_are_imported(self) -> None: + def test_explicit_category_path_is_imported(self) -> None: response = self._import( [ { "title": "FoxRay support", "description": "Support rules", "categoryPath": ["Products", "FoxRay"], - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentCodes": ["sales", "support"], "content": "Procedure", } ] @@ -74,19 +65,8 @@ class KnowledgeMetadataImportTests(TestCase): self.assertEqual(response.json()["created"], 1) knowledge = Knowledge.objects.get(title="FoxRay support") self.assertEqual(knowledge.category_id, self.foxray.id) - self.assertEqual(knowledge.visibility, KnowledgeVisibility.DEPARTMENTS) - self.assertEqual( - set(knowledge.departments.values_list("code", flat=True)), - {"sales", "support"}, - ) - def test_unknown_category_and_disabled_department_fail_per_document(self) -> None: - disabled = Department.objects.create( - organization=self.organization, - code="disabled", - name="Disabled", - status=DepartmentStatus.DISABLED, - ) + def test_unknown_category_path_fails_per_document(self) -> None: response = self._import( [ { @@ -94,21 +74,14 @@ class KnowledgeMetadataImportTests(TestCase): "categoryPath": ["Products", "Missing"], "content": "No", }, - { - "title": "Disabled department", - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentCodes": [disabled.code], - "content": "No", - }, {"title": "Valid", "content": "Yes"}, ] ) payload = response.json() self.assertEqual(payload["created"], 1) - self.assertEqual(len(payload["failed"]), 2) + self.assertEqual(len(payload["failed"]), 1) self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists()) - self.assertFalse(Knowledge.objects.filter(title="Disabled department").exists()) self.assertFalse( KnowledgeCategory.objects.filter( organization=self.organization, @@ -116,14 +89,12 @@ class KnowledgeMetadataImportTests(TestCase): ).exists() ) - def test_omitted_metadata_preserves_scope_category_and_agent_links(self) -> None: + def test_omitted_metadata_preserves_category_and_agent_links(self) -> None: self._import( [ { "title": "Preserved", "categoryPath": ["Products", "FoxRay"], - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentCodes": ["support"], "content": "Version one", } ] @@ -133,7 +104,6 @@ class KnowledgeMetadataImportTests(TestCase): organization=self.organization, code="import-support", name="Import support", - department=self.support, ) agent = AIAgent.objects.create( channel=channel, @@ -147,11 +117,6 @@ class KnowledgeMetadataImportTests(TestCase): self.assertEqual(response.json()["updated"], 1) knowledge.refresh_from_db() self.assertEqual(knowledge.category_id, self.foxray.id) - self.assertEqual(knowledge.visibility, KnowledgeVisibility.DEPARTMENTS) - self.assertEqual( - list(knowledge.departments.values_list("id", flat=True)), - [self.support.id], - ) self.assertTrue(agent.knowledge_items.filter(id=knowledge.id).exists()) def test_metadata_only_update_keeps_existing_fragments(self) -> None: @@ -164,8 +129,6 @@ class KnowledgeMetadataImportTests(TestCase): { "title": "Metadata", "categoryPath": ["Products", "FoxRay"], - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentCodes": ["sales"], "content": "Stable content", } ] @@ -173,77 +136,36 @@ class KnowledgeMetadataImportTests(TestCase): self.assertEqual(response.json()["updated"], 1) knowledge.refresh_from_db() + self.assertEqual(knowledge.category_id, self.foxray.id) self.assertEqual( list(knowledge.fragments.values_list("id", flat=True)), fragment_ids, ) - def test_explicit_organization_scope_clears_department_links(self) -> None: - self._import( - [ - { - "title": "Scope reset", - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentCodes": ["sales"], - "content": "Stable", - } - ] - ) - - response = self._import( - [ - { - "title": "Scope reset", - "visibility": KnowledgeVisibility.ORGANIZATION, - "content": "Stable", - } - ] - ) - - self.assertEqual(response.json()["updated"], 1) - knowledge = Knowledge.objects.get(title="Scope reset") - self.assertEqual(knowledge.visibility, KnowledgeVisibility.ORGANIZATION) - self.assertFalse(knowledge.department_links.exists()) - class KnowledgeImportPolicyTests(KnowledgePolicyTestBase): def setUp(self) -> None: super().setUp() - from hub_platform.subscriptions.testing import create_test_subscription - create_test_subscription(self.organization) self.client = TenantAPIClient() - self.client.force_login(self.sales_employee.user) + self.client.force_login(self.employee.user) - def _import(self, documents: list[dict[str, object]]): - return self.client.post( + def test_employee_cannot_import_knowledge(self) -> None: + response = self.client.post( "/api/v1/ai/knowledge/import/", - data=json.dumps({"documents": documents}), + data=json.dumps( + { + "documents": [ + { + "title": self.support_only.title, + "content": "Attempted overwrite", + } + ] + } + ), content_type="application/json", ) - def test_department_manager_is_checked_for_each_document(self) -> None: - response = self._import( - [ - { - "title": "New sales", - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentCodes": ["sales"], - "content": "Allowed", - }, - { - "title": "New organization", - "content": "Denied", - }, - { - "title": self.support_only.title, - "content": "Attempted overwrite", - }, - ] - ) - - payload = response.json() - self.assertEqual(payload["created"], 1) - self.assertEqual(len(payload["failed"]), 2) + self.assertEqual(response.status_code, 403) self.support_only.refresh_from_db() self.assertEqual(self.support_only.content, "") diff --git a/apps/backend/hub_platform/ai/test_knowledge_policy.py b/apps/backend/hub_platform/ai/test_knowledge_policy.py index 7b21aa0..8bc9b42 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_policy.py +++ b/apps/backend/hub_platform/ai/test_knowledge_policy.py @@ -1,11 +1,12 @@ +from django.core.exceptions import PermissionDenied + from hub_platform.ai.knowledge_policy import ( - employee_can_create_knowledge, employee_can_manage_categories, employee_can_read_knowledge, employee_can_write_knowledge, + require_knowledge_create, ) from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.models import Knowledge from hub_platform.ai.selectors import ( knowledge_for_employee, @@ -14,24 +15,26 @@ from hub_platform.ai.selectors import ( class KnowledgePolicyTests(KnowledgePolicyTestBase): - def test_read_policy_is_tenant_safe_and_department_scoped(self) -> None: - visible_ids = set( - knowledge_for_employee(context=self.sales_context).values_list( - "id", flat=True + def _all_ids(self) -> set[int]: + return { + self.shared.id, + self.sales_only.id, + self.support_only.id, + self.disabled.id, + } + + def test_read_policy_is_tenant_safe_and_role_based(self) -> None: + for context in (self.owner_context, self.admin_context): + visible_ids = set( + knowledge_for_employee(context=context).values_list("id", flat=True) ) - ) + self.assertEqual(visible_ids, self._all_ids()) self.assertEqual( - visible_ids, - { - self.shared.id, - self.sales_only.id, - self.multi_department.id, - self.disabled_sales.id, - }, + list(knowledge_for_employee(context=self.employee_context)), [] ) self.assertFalse( employee_can_read_knowledge( - context=self.sales_context, knowledge=self.support_only + context=self.employee_context, knowledge=self.shared ) ) other_knowledge = Knowledge.objects.create( @@ -45,59 +48,44 @@ class KnowledgePolicyTests(KnowledgePolicyTestBase): ) ) - def test_write_policy_requires_full_current_and_new_department_coverage(self) -> None: - sales_writable_ids = set( - writable_knowledge_for_employee( - context=self.sales_context - ).values_list("id", flat=True) - ) - self.assertEqual(sales_writable_ids, {self.sales_only.id, self.disabled_sales.id}) - self.assertFalse( - employee_can_write_knowledge( - context=self.sales_context, knowledge=self.shared + def test_write_policy_is_role_based(self) -> None: + admin_writable_ids = set( + writable_knowledge_for_employee(context=self.admin_context).values_list( + "id", flat=True ) ) - self.assertFalse( - employee_can_write_knowledge( - context=self.sales_context, knowledge=self.multi_department - ) - ) - self.assertFalse( - employee_can_write_knowledge( - context=self.sales_context, - knowledge=self.sales_only, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=[self.sales.id, self.support.id], - ) + self.assertEqual(admin_writable_ids, self._all_ids()) + self.assertEqual( + list(writable_knowledge_for_employee(context=self.employee_context)), [] ) self.assertTrue( employee_can_write_knowledge( - context=self.all_departments_context, - knowledge=self.multi_department, + context=self.owner_context, knowledge=self.shared ) ) - self.assertTrue( + self.assertFalse( employee_can_write_knowledge( - context=self.organization_manager_context, knowledge=self.shared + context=self.employee_context, knowledge=self.sales_only + ) + ) + other_knowledge = Knowledge.objects.create( + organization=self.other_organization, + category=self.other_category, + title="Other tenant", + ) + self.assertFalse( + employee_can_write_knowledge( + context=self.owner_context, knowledge=other_knowledge ) ) - def test_create_and_category_policy_distinguish_department_and_org_scope(self) -> None: - self.assertTrue( - employee_can_create_knowledge( - context=self.sales_context, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=[self.sales.id], - ) - ) + def test_create_and_category_policy_require_manage_role(self) -> None: + require_knowledge_create(context=self.owner_context) + require_knowledge_create(context=self.admin_context) + with self.assertRaises(PermissionDenied): + require_knowledge_create(context=self.employee_context) + self.assertTrue(employee_can_manage_categories(context=self.owner_context)) + self.assertTrue(employee_can_manage_categories(context=self.admin_context)) self.assertFalse( - employee_can_create_knowledge( - context=self.sales_context, - visibility=KnowledgeVisibility.ORGANIZATION, - department_ids=[], - ) - ) - self.assertFalse(employee_can_manage_categories(context=self.sales_context)) - self.assertTrue( - employee_can_manage_categories(context=self.organization_manager_context) + employee_can_manage_categories(context=self.employee_context) ) diff --git a/apps/backend/hub_platform/ai/test_knowledge_policy_api.py b/apps/backend/hub_platform/ai/test_knowledge_policy_api.py index dd2ad6d..010a8b1 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_policy_api.py +++ b/apps/backend/hub_platform/ai/test_knowledge_policy_api.py @@ -12,22 +12,34 @@ class KnowledgePolicyApiTests(KnowledgePolicyTestBase): super().setUp() create_test_subscription(self.organization) self.client = TenantAPIClient() - self.client.force_authenticate(self.sales_employee.user) + self.client.force_authenticate(self.admin.user) - def test_list_detail_and_filters_do_not_disclose_other_department(self) -> None: - response = self.client.get("/api/v1/ai/knowledge/?search=support") + def test_admin_sees_whole_library_and_employee_gets_403(self) -> None: + response = self.client.get("/api/v1/ai/knowledge/") self.assertEqual(response.status_code, 200) self.assertEqual( {item["id"] for item in response.json()["items"]}, - {self.multi_department.id}, + { + self.shared.id, + self.sales_only.id, + self.support_only.id, + self.disabled.id, + }, ) - hidden = self.client.get( - f"/api/v1/ai/knowledge/{self.support_only.id}/" - ) - self.assertEqual(hidden.status_code, 404) + detail = self.client.get(f"/api/v1/ai/knowledge/{self.support_only.id}/") + self.assertEqual(detail.status_code, 200) - def test_mutations_require_complete_write_coverage(self) -> None: + self.client.force_authenticate(self.employee.user) + self.assertEqual(self.client.get("/api/v1/ai/knowledge/").status_code, 403) + self.assertEqual( + self.client.get( + f"/api/v1/ai/knowledge/{self.shared.id}/" + ).status_code, + 403, + ) + + def test_admin_mutations_allowed_and_employee_denied(self) -> None: allowed = self.client.patch( f"/api/v1/ai/knowledge/{self.sales_only.id}/", data=json.dumps({"title": "Updated sales playbook"}), @@ -35,48 +47,47 @@ class KnowledgePolicyApiTests(KnowledgePolicyTestBase): ) self.assertEqual(allowed.status_code, 200) - for knowledge in (self.shared, self.support_only, self.multi_department): - with self.subTest(knowledge_id=knowledge.id): - denied = self.client.patch( - f"/api/v1/ai/knowledge/{knowledge.id}/", - data=json.dumps({"title": "Forbidden update"}), - content_type="application/json", - ) - self.assertEqual(denied.status_code, 404) - - create = self.client.post( + created = self.client.post( "/api/v1/ai/knowledge/", data=json.dumps({"title": "Organization item"}), content_type="application/json", ) - self.assertEqual(create.status_code, 403) + self.assertEqual(created.status_code, 201) - def test_agent_endpoints_keep_channel_department_scope(self) -> None: - sales_channel = Channel.objects.create( + self.client.force_authenticate(self.employee.user) + denied_patch = self.client.patch( + f"/api/v1/ai/knowledge/{self.sales_only.id}/", + data=json.dumps({"title": "Forbidden update"}), + content_type="application/json", + ) + self.assertEqual(denied_patch.status_code, 403) + denied_create = self.client.post( + "/api/v1/ai/knowledge/", + data=json.dumps({"title": "Forbidden item"}), + content_type="application/json", + ) + self.assertEqual(denied_create.status_code, 403) + + def test_agent_endpoints_are_organization_wide_and_closed_for_employee(self) -> None: + channel = Channel.objects.create( organization=self.organization, - department=self.sales, - code="api-sales-agent", - name="Sales agent channel", - ) - support_channel = Channel.objects.create( - organization=self.organization, - department=self.support, - code="api-support-agent", - name="Support agent channel", - ) - sales_agent = AIAgent.objects.create( - channel=sales_channel, name="Sales agent" - ) - support_agent = AIAgent.objects.create( - channel=support_channel, name="Support agent" + code="api-agent", + name="Agent channel", ) + agent = AIAgent.objects.create(channel=channel, name="Agent") response = self.client.get("/api/v1/ai/agents/") self.assertEqual(response.status_code, 200) self.assertEqual( [item["id"] for item in response.json()["items"]], - [sales_agent.id], + [agent.id], + ) + detail = self.client.get(f"/api/v1/ai/agents/{agent.id}/") + self.assertEqual(detail.status_code, 200) + + self.client.force_authenticate(self.employee.user) + self.assertEqual(self.client.get("/api/v1/ai/agents/").status_code, 403) + self.assertEqual( + self.client.get(f"/api/v1/ai/agents/{agent.id}/").status_code, 403 ) - hidden = self.client.get(f"/api/v1/ai/agents/{support_agent.id}/") - self.assertEqual(hidden.status_code, 404) diff --git a/apps/backend/hub_platform/ai/test_knowledge_scope_conflicts.py b/apps/backend/hub_platform/ai/test_knowledge_scope_conflicts.py deleted file mode 100644 index 03f51cf..0000000 --- a/apps/backend/hub_platform/ai/test_knowledge_scope_conflicts.py +++ /dev/null @@ -1,266 +0,0 @@ -import json - -from django.test import TestCase - -from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.knowledge_visibility import replace_knowledge_visibility -from hub_platform.ai.models import AIAgent, AIAgentStatus -from hub_platform.channels.models import Channel -from hub_platform.identity.bootstrap import bootstrap_edevs_owner -from hub_platform.testing import TenantAPIClient, system_tenant_context - - -class KnowledgeScopeConflictTests(TestCase): - def setUp(self) -> None: - result = bootstrap_edevs_owner( - email="owner@edevs.tech", - password="temporary-password", - ) - self.organization = result.organization - self.sales = result.sales_department - self.support = result.support_department - self.context = system_tenant_context(self.organization) - self.sales_channel = Channel.objects.create( - organization=self.organization, - code="conflict-sales", - name="Sales channel", - department=self.sales, - ) - self.support_channel = Channel.objects.create( - organization=self.organization, - code="conflict-support", - name="Support channel", - department=self.support, - ) - self.client = TenantAPIClient() - self.client.login( - username="owner@edevs.tech", - password="temporary-password", - ) - - def _knowledge( - self, - *, - title: str = "Policy", - visibility: str = KnowledgeVisibility.ORGANIZATION, - department_ids: tuple[int, ...] = (), - ): - return create_knowledge( - context=self.context, - data=KnowledgeInput( - title=title, - description="", - content="Policy text", - is_enabled=True, - visibility=visibility, - department_ids=department_ids, - ), - ) - - def _agent( - self, - channel: Channel, - knowledge, - *, - name: str, - status: str = AIAgentStatus.ACTIVE, - ) -> AIAgent: - agent = AIAgent.objects.create( - channel=channel, - name=name, - status=status, - ) - agent.knowledge_items.add(knowledge) - return agent - - def test_visibility_change_returns_stable_conflicts_and_rolls_back(self) -> None: - knowledge = self._knowledge() - sales_agent = self._agent( - self.sales_channel, - knowledge, - name="Sales agent", - ) - self._agent( - self.support_channel, - knowledge, - name="Support agent", - ) - - response = self.client.patch( - f"/api/v1/ai/knowledge/{knowledge.id}/", - data=json.dumps( - { - "title": "Changed title", - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentIds": [self.support.id], - } - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 409) - payload = response.json() - self.assertEqual(payload["code"], "agent_knowledge_scope_conflict") - self.assertEqual( - payload["conflicts"], - [ - { - "agent": {"id": sales_agent.id, "name": "Sales agent"}, - "knowledge": {"id": knowledge.id, "title": "Policy"}, - } - ], - ) - knowledge.refresh_from_db() - self.assertEqual(knowledge.title, "Policy") - self.assertEqual(knowledge.visibility, KnowledgeVisibility.ORGANIZATION) - self.assertFalse(knowledge.department_links.exists()) - - def test_department_link_replacement_is_blocked_without_partial_change( - self, - ) -> None: - knowledge = self._knowledge( - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=(self.sales.id, self.support.id), - ) - self._agent(self.sales_channel, knowledge, name="Sales agent") - - response = self.client.patch( - f"/api/v1/ai/knowledge/{knowledge.id}/", - data=json.dumps({"departmentIds": [self.support.id]}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 409) - knowledge.refresh_from_db() - self.assertEqual( - set(knowledge.department_links.values_list("department_id", flat=True)), - {self.sales.id, self.support.id}, - ) - - def test_disabled_non_archived_agent_still_blocks_scope_change(self) -> None: - knowledge = self._knowledge() - self._agent( - self.sales_channel, - knowledge, - name="Disabled sales agent", - status=AIAgentStatus.DISABLED, - ) - - response = self.client.patch( - f"/api/v1/ai/knowledge/{knowledge.id}/", - data=json.dumps( - { - "visibility": KnowledgeVisibility.DEPARTMENTS, - "departmentIds": [self.support.id], - } - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 409) - - def test_archived_agent_does_not_block_scope_change(self) -> None: - knowledge = self._knowledge() - self._agent( - self.sales_channel, - knowledge, - name="Archived sales agent", - status=AIAgentStatus.ARCHIVED, - ) - - replace_knowledge_visibility( - context=self.context, - knowledge=knowledge, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=[self.support.id], - ) - - knowledge.refresh_from_db() - self.assertEqual(knowledge.visibility, KnowledgeVisibility.DEPARTMENTS) - self.assertEqual( - list(knowledge.department_links.values_list("department_id", flat=True)), - [self.support.id], - ) - - -class ChannelDepartmentConflictTests(TestCase): - def setUp(self) -> None: - result = bootstrap_edevs_owner( - email="owner@edevs.tech", - password="temporary-password", - ) - self.organization = result.organization - self.sales = result.sales_department - self.support = result.support_department - self.context = system_tenant_context(self.organization) - self.channel = Channel.objects.create( - organization=self.organization, - code="department-change", - name="Original channel", - department=self.sales, - ) - self.knowledge = create_knowledge( - context=self.context, - data=KnowledgeInput( - title="Sales only", - description="", - content="Sales procedure", - is_enabled=True, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=(self.sales.id,), - ), - ) - self.agent = AIAgent.objects.create( - channel=self.channel, - name="Sales agent", - status=AIAgentStatus.ACTIVE, - ) - self.agent.knowledge_items.add(self.knowledge) - self.client = TenantAPIClient() - self.client.login( - username="owner@edevs.tech", - password="temporary-password", - ) - - def test_channel_department_change_is_blocked_and_atomic(self) -> None: - response = self.client.patch( - f"/api/v1/channels/{self.channel.id}/", - data=json.dumps({"name": "Changed channel", "departmentId": self.support.id}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 409) - self.assertEqual( - response.json()["conflicts"], - [ - { - "agent": {"id": self.agent.id, "name": "Sales agent"}, - "knowledge": { - "id": self.knowledge.id, - "title": "Sales only", - }, - } - ], - ) - self.channel.refresh_from_db() - self.assertEqual(self.channel.name, "Original channel") - self.assertEqual(self.channel.department_id, self.sales.id) - - def test_compatible_channel_department_change_succeeds(self) -> None: - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=[self.sales.id, self.support.id], - ) - - response = self.client.patch( - f"/api/v1/channels/{self.channel.id}/", - data=json.dumps({"departmentId": self.support.id}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 200) - self.channel.refresh_from_db() - self.assertEqual(self.channel.department_id, self.support.id) diff --git a/apps/backend/hub_platform/ai/test_knowledge_selectors.py b/apps/backend/hub_platform/ai/test_knowledge_selectors.py index 963ef19..f37163f 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_selectors.py +++ b/apps/backend/hub_platform/ai/test_knowledge_selectors.py @@ -1,7 +1,7 @@ -from hub_platform.ai.knowledge_policy import knowledge_is_available_to_agent from hub_platform.ai.knowledge_categories import create_category +from hub_platform.ai.knowledge_policy import knowledge_is_available_to_agent from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase -from hub_platform.ai.models import AIAgent +from hub_platform.ai.models import AIAgent, Knowledge from hub_platform.ai.selectors import ( KnowledgeFilters, agent_for_employee, @@ -17,25 +17,37 @@ from hub_platform.channels.models import Channel class KnowledgeSelectorTests(KnowledgePolicyTestBase): def test_filters_run_on_the_authorized_queryset(self) -> None: - base = knowledge_for_employee(context=self.sales_context) + base = knowledge_for_employee(context=self.admin_context) search_ids = set( apply_knowledge_filters( base, KnowledgeFilters(search="support") ).values_list("id", flat=True) ) - self.assertEqual(search_ids, {self.multi_department.id}) + self.assertEqual(search_ids, {self.support_only.id}) disabled_ids = set( apply_knowledge_filters( base, KnowledgeFilters(is_enabled=False) ).values_list("id", flat=True) ) - self.assertEqual(disabled_ids, {self.disabled_sales.id}) - support_filter_ids = set( + self.assertEqual(disabled_ids, {self.disabled.id}) + category_ids = set( apply_knowledge_filters( - base, KnowledgeFilters(department_id=self.support.id) + base, KnowledgeFilters(category_id=self.products.id) ).values_list("id", flat=True) ) - self.assertEqual(support_filter_ids, {self.multi_department.id}) + self.assertEqual( + category_ids, + { + self.shared.id, + self.sales_only.id, + self.support_only.id, + self.disabled.id, + }, + ) + employee_base = knowledge_for_employee(context=self.employee_context) + self.assertEqual( + list(apply_knowledge_filters(employee_base, KnowledgeFilters())), [] + ) def test_category_counts_are_computed_after_authorization(self) -> None: child = create_category( @@ -43,107 +55,96 @@ class KnowledgeSelectorTests(KnowledgePolicyTestBase): parent=self.products, name="Nested", ) - nested = self._knowledge("Nested sales", "Nested content") + nested = self._knowledge("Nested item", "Nested content") nested.category = child nested.save(update_fields=["category"]) - self._scope(nested, self.sales) - categories = category_tree_for_employee(context=self.sales_context) + categories = category_tree_for_employee(context=self.admin_context) by_id = {category.id: category for category in categories} self.assertEqual(by_id[self.products.id].knowledge_count, 5) self.assertEqual(by_id[child.id].knowledge_count, 1) self.assertLess(categories.index(self.products), categories.index(child)) - def test_agent_selector_and_policy_use_channel_department(self) -> None: - sales_channel = Channel.objects.create( - organization=self.organization, - department=self.sales, - code="sales-ai", - name="Sales AI", + employee_categories = category_tree_for_employee( + context=self.employee_context ) - sales_agent = AIAgent.objects.create(channel=sales_channel, name="Sales agent") + employee_by_id = {category.id: category for category in employee_categories} + self.assertEqual(employee_by_id[self.products.id].knowledge_count, 0) + self.assertEqual(employee_by_id[child.id].knowledge_count, 0) + + def test_agent_selectors_are_organization_scoped_and_role_gated(self) -> None: + channel = Channel.objects.create( + organization=self.organization, + code="org-ai", + name="Org AI", + ) + agent = AIAgent.objects.create(channel=channel, name="Org agent") + allowed_ids = set( - knowledge_available_to_agent(agent=sales_agent).values_list("id", flat=True) + knowledge_available_to_agent(agent=agent).values_list("id", flat=True) ) self.assertEqual( allowed_ids, { self.shared.id, self.sales_only.id, - self.multi_department.id, - self.disabled_sales.id, + self.support_only.id, + self.disabled.id, }, ) self.assertTrue( - knowledge_is_available_to_agent( - knowledge=self.sales_only, agent=sales_agent - ) + knowledge_is_available_to_agent(knowledge=self.sales_only, agent=agent) + ) + other_knowledge = Knowledge.objects.create( + organization=self.other_organization, + category=self.other_category, + title="Other tenant", ) self.assertFalse( - knowledge_is_available_to_agent( - knowledge=self.support_only, agent=sales_agent - ) + knowledge_is_available_to_agent(knowledge=other_knowledge, agent=agent) ) - support_channel = Channel.objects.create( - organization=self.organization, - department=self.support, - code="support-ai", - name="Support AI", + self.assertEqual( + list( + agents_for_employee( + context=self.admin_context, capability="ai.view" + ).values_list("id", flat=True) + ), + [agent.id], ) - support_agent = AIAgent.objects.create( - channel=support_channel, name="Support agent" + self.assertEqual( + agent_for_employee( + context=self.admin_context, + agent_id=agent.id, + capability="ai.view", + ), + agent, ) self.assertEqual( list( agents_for_employee( - context=self.sales_context, capability="ai.view" - ).values_list("id", flat=True) + context=self.employee_context, capability="ai.view" + ) ), - [sales_agent.id], - ) - self.assertEqual( - agent_for_employee( - context=self.sales_context, - agent_id=sales_agent.id, - capability="ai.view", - ), - sales_agent, + [], ) with self.assertRaises(AIAgent.DoesNotExist): agent_for_employee( - context=self.sales_context, - agent_id=support_agent.id, + context=self.employee_context, + agent_id=agent.id, capability="ai.view", ) self.assertEqual( channel_for_ai_capability( - context=self.sales_context, - channel_code=sales_channel.code, + context=self.admin_context, + channel_code=channel.code, capability="ai.manage", ), - sales_channel, + channel, ) with self.assertRaises(Channel.DoesNotExist): channel_for_ai_capability( - context=self.sales_context, - channel_code=support_channel.code, + context=self.employee_context, + channel_code=channel.code, capability="ai.manage", ) - - no_department_channel = Channel.objects.create( - organization=self.organization, - code="shared-ai", - name="Shared AI", - ) - no_department_agent = AIAgent.objects.create( - channel=no_department_channel, name="Shared agent" - ) - self.assertEqual( - set( - knowledge_available_to_agent( - agent=no_department_agent - ).values_list("id", flat=True) - ), - {self.shared.id}, - ) diff --git a/apps/backend/hub_platform/ai/test_knowledge_visibility.py b/apps/backend/hub_platform/ai/test_knowledge_visibility.py deleted file mode 100644 index 3f74287..0000000 --- a/apps/backend/hub_platform/ai/test_knowledge_visibility.py +++ /dev/null @@ -1,151 +0,0 @@ -from django.core.exceptions import ValidationError -from django.test import TestCase - -from hub_platform.ai.knowledge_categories import ensure_uncategorized_category -from hub_platform.ai.knowledge_types import KnowledgeVisibility -from hub_platform.ai.knowledge_visibility import replace_knowledge_visibility -from hub_platform.ai.models import Knowledge, KnowledgeCategory, KnowledgeDepartment -from hub_platform.identity.models import ( - Department, - DepartmentStatus, - Organization, -) -from hub_platform.tenancy.context import TenantContext - - -class KnowledgeVisibilityTests(TestCase): - def setUp(self) -> None: - self.organization = Organization.objects.create(name="Example", slug="visibility-example") - self.other_organization = Organization.objects.create( - name="Other", slug="visibility-other" - ) - ensure_uncategorized_category(self.organization) - ensure_uncategorized_category(self.other_organization) - self.context = TenantContext.for_resource(self.organization) - self.sales = Department.objects.create( - organization=self.organization, - code="sales", - name="Sales", - ) - self.support = Department.objects.create( - organization=self.organization, - code="support", - name="Support", - ) - self.disabled = Department.objects.create( - organization=self.organization, - code="disabled", - name="Disabled", - status=DepartmentStatus.DISABLED, - ) - self.other_department = Department.objects.create( - organization=self.other_organization, - code="other", - name="Other", - ) - self.knowledge = Knowledge.objects.create( - organization=self.organization, - category=KnowledgeCategory.objects.get( - organization=self.organization, - is_system=True, - ), - title="Shared knowledge", - ) - - def test_department_visibility_requires_active_same_tenant_departments(self) -> None: - for invalid_ids in ([], [self.disabled.id], [self.other_department.id], [999999]): - with self.subTest(department_ids=invalid_ids): - with self.assertRaises(ValidationError): - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=invalid_ids, - ) - - self.knowledge.refresh_from_db() - self.assertEqual(self.knowledge.visibility, KnowledgeVisibility.ORGANIZATION) - self.assertFalse(self.knowledge.department_links.exists()) - - def test_department_visibility_replaces_links_atomically(self) -> None: - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=[self.sales.id, self.support.id, self.sales.id], - ) - self.assertEqual( - set(self.knowledge.departments.values_list("id", flat=True)), - {self.sales.id, self.support.id}, - ) - - with self.assertRaises(ValidationError): - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=[self.disabled.id], - ) - self.knowledge.refresh_from_db() - self.assertEqual(self.knowledge.visibility, KnowledgeVisibility.DEPARTMENTS) - self.assertEqual( - set(self.knowledge.departments.values_list("id", flat=True)), - {self.sales.id, self.support.id}, - ) - - def test_organization_visibility_requires_and_restores_empty_links(self) -> None: - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=[self.sales.id], - ) - with self.assertRaises(ValidationError): - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility=KnowledgeVisibility.ORGANIZATION, - department_ids=[self.sales.id], - ) - - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility=KnowledgeVisibility.ORGANIZATION, - department_ids=[], - ) - self.knowledge.refresh_from_db() - self.assertEqual(self.knowledge.visibility, KnowledgeVisibility.ORGANIZATION) - self.assertFalse(self.knowledge.department_links.exists()) - - def test_unknown_visibility_and_non_integer_ids_are_rejected(self) -> None: - with self.assertRaises(ValidationError): - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility="PUBLIC", - department_ids=[], - ) - with self.assertRaises(ValidationError): - replace_knowledge_visibility( - context=self.context, - knowledge=self.knowledge, - visibility=KnowledgeVisibility.DEPARTMENTS, - department_ids=["sales"], - ) - - def test_direct_through_link_rejects_cross_tenant_and_disabled_department(self) -> None: - self.knowledge.visibility = KnowledgeVisibility.DEPARTMENTS - self.knowledge.save(update_fields=["visibility"]) - with self.assertRaises(ValidationError): - KnowledgeDepartment.objects.create( - organization=self.organization, - knowledge=self.knowledge, - department=self.other_department, - ) - with self.assertRaises(ValidationError): - KnowledgeDepartment.objects.create( - organization=self.organization, - knowledge=self.knowledge, - department=self.disabled, - ) diff --git a/apps/backend/hub_platform/ai/tests.py b/apps/backend/hub_platform/ai/tests.py index 5d3aa3a..f011fdc 100644 --- a/apps/backend/hub_platform/ai/tests.py +++ b/apps/backend/hub_platform/ai/tests.py @@ -207,7 +207,6 @@ class AIAgentPermissionTests(TestCase): organization=Organization.objects.get(slug="edevs"), role=EmployeeRole.EMPLOYEE, position_title="Оператор", - primary_department=None, ) self.client = APIClient() self.client.login(username="operator@edevs.tech", password="operator-password") diff --git a/apps/backend/hub_platform/ai/urls.py b/apps/backend/hub_platform/ai/urls.py index dca145e..054fc45 100644 --- a/apps/backend/hub_platform/ai/urls.py +++ b/apps/backend/hub_platform/ai/urls.py @@ -31,11 +31,6 @@ urlpatterns = [ bulk_views.KnowledgeBulkMoveView.as_view(), name="ai-knowledge-bulk-move", ), - path( - "knowledge/bulk/visibility/", - bulk_views.KnowledgeBulkVisibilityView.as_view(), - name="ai-knowledge-bulk-visibility", - ), path( "knowledge/bulk/agent/", bulk_views.AgentKnowledgeLinkView.as_view(), diff --git a/apps/backend/hub_platform/ai/views.py b/apps/backend/hub_platform/ai/views.py index 30c97df..49f174c 100644 --- a/apps/backend/hub_platform/ai/views.py +++ b/apps/backend/hub_platform/ai/views.py @@ -6,7 +6,6 @@ from rest_framework.views import APIView from hub_platform.ai.api_errors import validation_error_response from hub_platform.ai.knowledge_api_inputs import knowledge_filters, knowledge_input -from hub_platform.ai.knowledge_conflicts import KnowledgeScopeConflict from hub_platform.ai.knowledge_import import import_knowledge_documents from hub_platform.ai.knowledge_policy import ( employee_can_write_knowledge, @@ -19,7 +18,6 @@ from hub_platform.ai.knowledge_services import ( delete_knowledge, update_knowledge, ) -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.models import Knowledge from hub_platform.ai.selectors import ( apply_knowledge_filters, @@ -82,11 +80,7 @@ class KnowledgeListCreateView(_KnowledgeBaseView): def post(self, request: Request) -> Response: try: data = knowledge_input(request.data) - require_knowledge_create( - context=request.tenant_context, - visibility=data.visibility or KnowledgeVisibility.ORGANIZATION, - department_ids=data.department_ids or (), - ) + require_knowledge_create(context=request.tenant_context) knowledge = create_knowledge( context=request.tenant_context, data=data, @@ -130,17 +124,13 @@ class KnowledgeDetailView(_KnowledgeBaseView): if not employee_can_write_knowledge( context=request.tenant_context, knowledge=knowledge, - visibility=data.visibility, - department_ids=data.department_ids, ): - raise PermissionDenied("Knowledge scope is not manageable") + raise PermissionDenied("Knowledge is not manageable") knowledge = update_knowledge( context=request.tenant_context, knowledge=knowledge, data=data, ) - except KnowledgeScopeConflict as error: - return Response(error.payload(), status=409) except ValidationError as error: return _validation_error(error) knowledge = self._write_knowledge(request, knowledge_id) diff --git a/apps/backend/hub_platform/api/permissions.py b/apps/backend/hub_platform/api/permissions.py index 0e327a4..ce9c494 100644 --- a/apps/backend/hub_platform/api/permissions.py +++ b/apps/backend/hub_platform/api/permissions.py @@ -3,13 +3,12 @@ from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.views import APIView -from hub_platform.identity.models import Department -from hub_platform.identity.policy import ResourceScope, authorize, has_capability_any_scope +from hub_platform.identity.policy import has_capability_any_scope from hub_platform.subscriptions.policy import get_effective_policy class HasCapability(BasePermission): - """DRF entry-point guard backed by the shared capability policy. + """DRF entry-point guard backed by the shared role policy (SPEC-HUB-0031 §3). Views declare ``required_capability`` or a method keyed ``required_capabilities`` mapping. Object/resource scope is still checked by the @@ -27,32 +26,7 @@ class HasCapability(BasePermission): context = getattr(request, "tenant_context", None) if context is None or context.membership is None: return False - profile = context.membership - department_code = getattr(view, "required_department_code", None) - if department_code: - department_id = ( - Department.objects.filter( - organization_id=profile.organization_id, - code=department_code, - ) - .values_list("id", flat=True) - .first() - ) - return bool(department_id) and authorize( - profile, - capability, - ResourceScope( - organization_id=profile.organization_id, - department_id=department_id, - ), - ) - if getattr(view, "require_organization_scope", False): - return authorize( - profile, - capability, - ResourceScope(organization_id=profile.organization_id), - ) - return has_capability_any_scope(profile, capability) + return has_capability_any_scope(context.membership, capability) class HasEntitlement(BasePermission): diff --git a/apps/backend/hub_platform/calls/permissions.py b/apps/backend/hub_platform/calls/permissions.py index 8af613c..1523abb 100644 --- a/apps/backend/hub_platform/calls/permissions.py +++ b/apps/backend/hub_platform/calls/permissions.py @@ -8,7 +8,11 @@ from hub_platform.tenancy.context import TenantContext def ensure_conversation_call_access(*, user, conversation: Conversation) -> None: if not require_capability(user, "conversations.call", conversation): - raise CallAccessDenied("Нет доступа к звонкам этого отдела") + raise CallAccessDenied("Нет доступа к звонкам") + from hub_platform.conversations.selectors import conversation_is_visible + + if not conversation_is_visible(actor=user, conversation=conversation): + raise CallAccessDenied("Диалог вне групп сотрудника") def ensure_call_access(*, user, call_session: CallSession) -> None: diff --git a/apps/backend/hub_platform/calls/tests/helpers.py b/apps/backend/hub_platform/calls/tests/helpers.py index 0f0dc08..4143370 100644 --- a/apps/backend/hub_platform/calls/tests/helpers.py +++ b/apps/backend/hub_platform/calls/tests/helpers.py @@ -46,13 +46,13 @@ class CallDomainMixin: def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.sales_department = self.organization.departments.get(code="sales") - self.support_department = self.organization.departments.get(code="support") + self.operators_group = self.organization.employee_groups.get(name="Операторы") + self.support_group = self.organization.employee_groups.get(name="Поддержка") self.owner = HumanUser.objects.get(email="owner@edevs.tech") self.operator = HumanUser.objects.get(email="a.kotova@edevs.tech") self.channel = Channel.objects.create( organization=self.organization, - department=self.sales_department, + group=self.operators_group, code="call-sales", name="Звонки — продажи", ) @@ -73,6 +73,7 @@ class CallDomainMixin: self.conversation = Conversation.objects.create( organization=self.organization, channel=self.channel, + group=self.operators_group, connection=self.connection, contact=self.contact, ) @@ -87,7 +88,6 @@ class CallDomainMixin: organization=self.organization, role=EmployeeRole.EMPLOYEE, position_title="Оператор поддержки", - primary_department=self.support_department, ) return user @@ -102,6 +102,7 @@ class CallDomainMixin: return Conversation.objects.create( organization=self.organization, channel=self.channel, + group=self.operators_group, connection=self.connection, contact=contact, ) diff --git a/apps/backend/hub_platform/calls/tests/test_api.py b/apps/backend/hub_platform/calls/tests/test_api.py index d129032..b461c7c 100644 --- a/apps/backend/hub_platform/calls/tests/test_api.py +++ b/apps/backend/hub_platform/calls/tests/test_api.py @@ -48,7 +48,7 @@ class InternalCallApiTests(CallTestCase): self.assertEqual(second.status_code, 409) self.assertEqual(set(second.json()), {"detail"}) - def test_other_department_operator_gets_403_without_takeover(self) -> None: + def test_operator_outside_group_gets_403(self) -> None: support_operator = self.create_support_operator() self.client.force_authenticate(user=support_operator) response = self.client.post( diff --git a/apps/backend/hub_platform/calls/tests/test_services.py b/apps/backend/hub_platform/calls/tests/test_services.py index fbefe9f..805dd6c 100644 --- a/apps/backend/hub_platform/calls/tests/test_services.py +++ b/apps/backend/hub_platform/calls/tests/test_services.py @@ -51,7 +51,7 @@ class CallCreationTests(CallTestCase): self.conversation.refresh_from_db() self.assertEqual(self.conversation.assigned_operator, self.operator) - def test_operator_from_other_department_is_denied_without_takeover(self) -> None: + def test_operator_outside_group_is_denied(self) -> None: support_operator = self.create_support_operator() with self.assertRaises(CallAccessDenied): diff --git a/apps/backend/hub_platform/channels/admin.py b/apps/backend/hub_platform/channels/admin.py index a798e6b..5579f57 100644 --- a/apps/backend/hub_platform/channels/admin.py +++ b/apps/backend/hub_platform/channels/admin.py @@ -5,6 +5,6 @@ from hub_platform.channels.models import Channel @admin.register(Channel) class ChannelAdmin(admin.ModelAdmin): - list_display = ("name", "code", "product", "department", "is_active") + list_display = ("name", "code", "product", "group", "is_active") list_filter = ("is_active",) search_fields = ("name", "code") diff --git a/apps/backend/hub_platform/channels/api_inputs.py b/apps/backend/hub_platform/channels/api_inputs.py index 0b7b3da..3755656 100644 --- a/apps/backend/hub_platform/channels/api_inputs.py +++ b/apps/backend/hub_platform/channels/api_inputs.py @@ -91,7 +91,7 @@ def parse_update(data: dict, *, current_code: str) -> ChannelUpdate: raise ValidationError({"code": "Код канала не изменяется после создания"}) return ChannelUpdate( name=data["name"] if "name" in data else UNSET, - department_id=_optional_id(data, "departmentId"), + group_id=_optional_id(data, "groupId"), product_id=_optional_id(data, "productId"), is_active=_optional_bool(data, "isActive"), policy=parse_policy_fields(data["policy"]) if "policy" in data else {}, diff --git a/apps/backend/hub_platform/channels/authorization.py b/apps/backend/hub_platform/channels/authorization.py index e9a2f51..d6ca2d6 100644 --- a/apps/backend/hub_platform/channels/authorization.py +++ b/apps/backend/hub_platform/channels/authorization.py @@ -1,20 +1,15 @@ -"""Пофайловая авторизация канала (SPEC-HUB-0027 §5.2, ADR-HUB-0037 §9). +"""Авторизация операций над каналами (SPEC-HUB-0031 §3). -Часть операций требует organization scope, потому что затрагивает коммерческую -границу и маршрутизацию всей организации: создание, удаление, деактивация, -продукт и любой из пяти флагов политики. Department-scoped `channels.manage` -меняет только `name` и `department` в пределах доступных отделов. +После упразднения отделов и scope-модели проверки сведены к роли: OWNER и +ADMIN управляют каналами, EMPLOYEE их не видит и не меняет. Названия helpers +сохранены, чтобы не менять все call sites одновременно. """ from __future__ import annotations from django.core.exceptions import PermissionDenied -from hub_platform.identity.policy import ( - ResourceScope, - accessible_department_ids, - authorize, -) +from hub_platform.identity.policy import has_capability_any_scope from hub_platform.tenancy.context import TenantContext CHANNELS_VIEW = "channels.view" @@ -29,69 +24,23 @@ def _membership(context: TenantContext): return membership -def department_ids_for(context: TenantContext, capability: str) -> set[int] | None: - """None — все отделы организации, set() — доступа нет.""" - membership = _membership(context) - if membership is None: - return set() - return accessible_department_ids(membership, capability) - - -def has_capability_in_scope( - context: TenantContext, capability: str, *, department_id: int | None = None -) -> bool: +def has_organization_capability(context: TenantContext, capability: str) -> bool: membership = _membership(context) if membership is None: return False - return authorize( - membership, - capability, - ResourceScope( - organization_id=context.organization_id, department_id=department_id - ), - ) - - -def has_organization_capability(context: TenantContext, capability: str) -> bool: - # ResourceScope без отдела покрывается только ORGANIZATION-назначением. - return has_capability_in_scope(context, capability) + return has_capability_any_scope(membership, capability) def require_organization_manage(context: TenantContext, *, operation: str) -> None: if not has_organization_capability(context, CHANNELS_MANAGE): - raise PermissionDenied( - f"{operation} требует organization-scoped channels.manage" - ) + raise PermissionDenied(f"{operation} требует channels.manage") -def require_channel_manage(context: TenantContext, *, department_id: int | None) -> None: - """Изменение в scope канала: department-scoped достаточно для своего отдела.""" - if not has_capability_in_scope(context, CHANNELS_MANAGE, department_id=department_id): +def require_channel_manage(context: TenantContext) -> None: + if not has_organization_capability(context, CHANNELS_MANAGE): raise PermissionDenied("Нет прав на изменение канала") -def require_department_change( - context: TenantContext, - *, - current_department_id: int | None, - target_department_id: int | None, -) -> None: - """Смена отдела требует прав на обе стороны перехода. - - Если одна из сторон `null` — назначение отдела каналу без отдела или снятие - отдела, — покрыть отсутствующую сторону department-назначением невозможно, а - канал без отдела department-scoped сотруднику вообще не виден. Поэтому такой - переход требует organization scope (SPEC §5.2). - """ - if current_department_id is None or target_department_id is None: - require_organization_manage(context, operation="Смена отдела канала") - return - for department_id in (current_department_id, target_department_id): - require_channel_manage(context, department_id=department_id) - - def require_connections_manage(context: TenantContext) -> None: if not has_organization_capability(context, INTEGRATIONS_MANAGE): - raise PermissionDenied( - "Привязка подключения требует organization-scoped integrations.manage" - ) + raise PermissionDenied("Привязка подключения требует integrations.manage") diff --git a/apps/backend/hub_platform/channels/migrations/0006_remove_channel_department.py b/apps/backend/hub_platform/channels/migrations/0006_remove_channel_department.py new file mode 100644 index 0000000..286f174 --- /dev/null +++ b/apps/backend/hub_platform/channels/migrations/0006_remove_channel_department.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + # Дроп после tenancy-гардов (RLS/триггеры ссылаются на эти таблицы). + ('tenancy', '0017_drop_commerce'), + ('channels', '0005_enforce_policy_invariants'), + ] + + operations = [ + migrations.RemoveField( + model_name='channel', + name='department', + ), + ] diff --git a/apps/backend/hub_platform/channels/migrations/0007_channel_group.py b/apps/backend/hub_platform/channels/migrations/0007_channel_group.py new file mode 100644 index 0000000..6edfb51 --- /dev/null +++ b/apps/backend/hub_platform/channels/migrations/0007_channel_group.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('channels', '0006_remove_channel_department'), + ('identity', '0020_employeegroup_employeegroupmember_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='identity.employeegroup'), + ), + ] diff --git a/apps/backend/hub_platform/channels/models.py b/apps/backend/hub_platform/channels/models.py index 167c899..8f11009 100644 --- a/apps/backend/hub_platform/channels/models.py +++ b/apps/backend/hub_platform/channels/models.py @@ -1,7 +1,7 @@ from django.db import models # Канал обработки — якорь AI-контекста (ADR-HUB-0019). Опциональный продукт, -# отдел перехвата, ссылка на провайдер-интеграцию. Поведение AI (модель, +# группа видимости, ссылка на провайдер-интеграцию. Поведение AI (модель, # инструкции, знания) живёт на агенте канала (ADR-HUB-0023). @@ -9,8 +9,9 @@ class Channel(models.Model): organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="channels") code = models.SlugField(max_length=64) name = models.CharField(max_length=255) - # Отдел, чьи операторы перехватывают диалоги канала. - department = models.ForeignKey("identity.Department", on_delete=models.PROTECT, related_name="channels", null=True, blank=True) + # Группа видимости (ADR-HUB-0043): новые диалоги канала попадают в неё. + # NULL — диалоги видны всем сотрудникам. + group = models.ForeignKey("identity.EmployeeGroup", on_delete=models.SET_NULL, related_name="channels", null=True, blank=True) # Продукт опционален: непродуктовый канал — главный сайт edevs. product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="channels", null=True, blank=True) # LLM-провайдер канала (ADR-HUB-0020). diff --git a/apps/backend/hub_platform/channels/selectors.py b/apps/backend/hub_platform/channels/selectors.py index 0e3f6e8..899d281 100644 --- a/apps/backend/hub_platform/channels/selectors.py +++ b/apps/backend/hub_platform/channels/selectors.py @@ -1,6 +1,6 @@ from django.db.models import Count, Prefetch, Q, QuerySet -from hub_platform.channels.authorization import CHANNELS_VIEW, department_ids_for +from hub_platform.channels.authorization import CHANNELS_VIEW, has_organization_capability from hub_platform.channels.models import Channel from hub_platform.conversations.models import LifecycleState from hub_platform.integrations.models import Integration @@ -9,7 +9,7 @@ from hub_platform.tenancy.context import TenantContext def _with_relations(queryset: QuerySet[Channel]) -> QuerySet[Channel]: return queryset.select_related( - "product", "department", "ai_agent", "ai_agent__provider_integration" + "product", "group", "ai_agent", "ai_agent__provider_integration" ).prefetch_related( Prefetch("connections", queryset=Integration.objects.order_by("id")) ).annotate( @@ -32,7 +32,7 @@ def channels_in_organization(context: TenantContext) -> QuerySet[Channel]: """ return ( Channel.objects.filter(organization_id=context.organization_id) - .select_related("product", "department", "ai_agent", "ai_agent__provider_integration") + .select_related("product", "group", "ai_agent", "ai_agent__provider_integration") .order_by("name") ) @@ -40,18 +40,14 @@ def channels_in_organization(context: TenantContext) -> QuerySet[Channel]: def channels_for_context( context: TenantContext, *, capability: str = CHANNELS_VIEW ) -> QuerySet[Channel]: - """Каналы организации, видимые актору (SPEC-HUB-0027 §5.2). - - Department-scoped доступ не выдаёт канал без отдела: у такого канала нет - отдела, который назначение могло бы покрыть. - """ + """Каналы организации, видимые актору: доступ ролевой (SPEC-HUB-0031 §3), + у EMPLOYEE нет channels.view — список пуст.""" queryset = _with_relations( Channel.objects.filter(organization_id=context.organization_id) ).order_by("name") - department_ids = department_ids_for(context, capability) - if department_ids is None: + if has_organization_capability(context, capability): return queryset - return queryset.filter(department_id__in=department_ids) + return queryset.none() def channel_for_context( @@ -72,13 +68,13 @@ def _parse_reference(raw: str) -> tuple[str, int | None]: def filter_channels(queryset: QuerySet[Channel], params) -> QuerySet[Channel]: """Фильтры §6.2. Scope уже применён селектором и здесь не расширяется.""" - department = params.get("department") - if department: - kind, value = _parse_reference(department) + group = params.get("group") + if group: + kind, value = _parse_reference(group) if kind == "none": - queryset = queryset.filter(department__isnull=True) + queryset = queryset.filter(group__isnull=True) elif kind == "id": - queryset = queryset.filter(department_id=value) + queryset = queryset.filter(group_id=value) product = params.get("product") if product: diff --git a/apps/backend/hub_platform/channels/serializers.py b/apps/backend/hub_platform/channels/serializers.py index 476e8a0..b7c773a 100644 --- a/apps/backend/hub_platform/channels/serializers.py +++ b/apps/backend/hub_platform/channels/serializers.py @@ -59,9 +59,8 @@ def channel_payload(channel: Channel) -> dict[str, object]: } if channel.product_id else None, - "departmentId": channel.department_id, - "department": channel.department.code if channel.department_id else None, - "departmentName": channel.department.name if channel.department_id else None, + "groupId": channel.group_id, + "groupName": channel.group.name if channel.group_id else None, "agent": agent, "connections": connections, "policy": policy.as_payload(), diff --git a/apps/backend/hub_platform/channels/services.py b/apps/backend/hub_platform/channels/services.py index 95ce348..294a224 100644 --- a/apps/backend/hub_platform/channels/services.py +++ b/apps/backend/hub_platform/channels/services.py @@ -9,11 +9,10 @@ from typing import Any from django.core.exceptions import ValidationError from django.db import transaction -from hub_platform.ai.knowledge_conflicts import require_channel_department_compatible from hub_platform.channels import authorization from hub_platform.channels.models import Channel from hub_platform.channels.policy import ChannelPolicy, require_valid_policy -from hub_platform.identity.models import Department, DepartmentStatus +from hub_platform.identity.group_models import EmployeeGroup from hub_platform.integrations.models import Integration, IntegrationKind from hub_platform.products.models import Product from hub_platform.tenancy.context import TenantContext @@ -76,7 +75,7 @@ class ChannelUpdate: """Частичное изменение: UNSET — поле не передано (SPEC §6.5).""" name: Any = UNSET - department_id: Any = UNSET + group_id: Any = UNSET product_id: Any = UNSET is_active: Any = UNSET policy: dict[str, bool] = field(default_factory=dict) @@ -100,19 +99,18 @@ def _clean_name(raw: object) -> str: return name -def _department_for_channel( - *, context: TenantContext, department_id: int | None -) -> Department | None: - if department_id is None: +def _group_for_channel( + *, context: TenantContext, group_id: int | None +) -> EmployeeGroup | None: + if group_id is None: return None try: - return Department.objects.get( - id=department_id, + return EmployeeGroup.objects.get( + id=group_id, organization_id=context.organization_id, - status=DepartmentStatus.ACTIVE, ) - except Department.DoesNotExist as error: - raise ValidationError({"departmentId": "Unknown or disabled department"}) from error + except EmployeeGroup.DoesNotExist as error: + raise ValidationError({"groupId": "Unknown group"}) from error def _product_for_channel( @@ -134,7 +132,7 @@ def create_channel( context: TenantContext, code: object, name: object, - department_id: int | None, + group_id: int | None, product_id: int | None, policy: ChannelPolicy, connection_ids: list[int] | None = None, @@ -142,7 +140,7 @@ def create_channel( authorization.require_organization_manage(context, operation="Создание канала") clean_code = _clean_code(code) clean_name = _clean_name(name) - department = _department_for_channel(context=context, department_id=department_id) + group = _group_for_channel(context=context, group_id=group_id) product = _product_for_channel(context=context, product_id=product_id) # Инварианты проверяются до записи: частичное применение запрещено (§3.2). require_valid_policy(policy=policy, has_product=product is not None) @@ -156,7 +154,7 @@ def create_channel( organization_id=context.organization_id, code=clean_code, name=clean_name, - department=department, + group=group, product=product, **policy.as_model_fields(), ) @@ -181,15 +179,9 @@ def update_channel( ) if update.name is not UNSET: - authorization.require_channel_manage( - context, department_id=locked.department_id - ) - if update.department_id is not UNSET and update.department_id != locked.department_id: - authorization.require_department_change( - context, - current_department_id=locked.department_id, - target_department_id=update.department_id, - ) + authorization.require_channel_manage(context) + if update.group_id is not UNSET and update.group_id != locked.group_id: + authorization.require_channel_manage(context) if update.product_id is not UNSET and update.product_id != locked.product_id: authorization.require_organization_manage( context, operation="Изменение продукта канала" @@ -209,15 +201,9 @@ def update_channel( if clean_name != locked.name: locked.name = clean_name changed.append("name") - if update.department_id is not UNSET and update.department_id != locked.department_id: - department = _department_for_channel( - context=context, department_id=update.department_id - ) - require_channel_department_compatible( - channel=locked, department_id=update.department_id - ) - locked.department = department - changed.append("department") + if update.group_id is not UNSET and update.group_id != locked.group_id: + locked.group = _group_for_channel(context=context, group_id=update.group_id) + changed.append("group") if update.product_id is not UNSET and update.product_id != locked.product_id: product = _product_for_channel(context=context, product_id=update.product_id) locked.product = product diff --git a/apps/backend/hub_platform/channels/tests.py b/apps/backend/hub_platform/channels/tests.py index f4d5744..e4538da 100644 --- a/apps/backend/hub_platform/channels/tests.py +++ b/apps/backend/hub_platform/channels/tests.py @@ -8,12 +8,8 @@ from hub_platform.testing import TenantAPIClient as APIClient from hub_platform.ai.models import AIAgent, AIAgentStatus from hub_platform.channels.models import Channel from hub_platform.identity.bootstrap import bootstrap_edevs_owner -from hub_platform.identity.capabilities import ScopeType +from hub_platform.identity.group_models import EmployeeGroup from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - Department, - EmployeeAccessAssignment, EmployeeRole, HumanUser, Organization, @@ -64,10 +60,8 @@ class ChannelApiTestCase(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.sales = Department.objects.get(organization=self.organization, code="sales") - self.support = Department.objects.get( - organization=self.organization, code="support" - ) + self.operators = self.organization.employee_groups.get(name="Операторы") + self.support_group = self.organization.employee_groups.get(name="Поддержка") self.product = Product.objects.get(organization=self.organization, code="foxray") self.client = APIClient() self.client.login(username="owner@edevs.tech", password="temporary-password") @@ -76,7 +70,7 @@ class ChannelApiTestCase(TestCase): body = { "code": "partners", "name": "Партнёрская линия", - "departmentId": None, + "groupId": None, "productId": None, "policyPreset": "CUSTOM", "policy": OPERATOR_POLICY, @@ -123,7 +117,7 @@ class ChannelCreateTests(ChannelApiTestCase): code="foxray-sales", name="FoxRay — продажи", productId=self.product.id, - departmentId=self.sales.id, + groupId=self.operators.id, policyPreset="SALES", policy=None, ) @@ -138,7 +132,7 @@ class ChannelCreateTests(ChannelApiTestCase): "code": "foxray-sales", "name": "FoxRay — продажи", "productId": self.product.id, - "departmentId": self.sales.id, + "groupId": self.operators.id, "policyPreset": "SALES", } ), @@ -358,7 +352,7 @@ class ChannelListTests(ChannelApiTestCase): super().setUp() self.sales_channel = _make_channel( self.organization, code="foxray-sales", name="FoxRay — продажи", - department=self.sales, product=self.product, + group=self.operators, product=self.product, ) self.orphan = _make_channel(self.organization, code="edevs", name="Edevs — сайт") self.archived = _make_channel( @@ -372,10 +366,10 @@ class ChannelListTests(ChannelApiTestCase): codes = {item["code"] for item in response.json()["items"]} self.assertEqual(codes, {"foxray-sales", "edevs", "old"}) - def test_filters_by_department_product_and_status(self) -> None: + def test_filters_by_group_product_and_status(self) -> None: cases = ( - ({"department": "none"}, {"edevs", "old"}), - ({"department": str(self.sales.id)}, {"foxray-sales"}), + ({"group": "none"}, {"edevs", "old"}), + ({"group": str(self.operators.id)}, {"foxray-sales"}), ({"product": "none"}, {"edevs", "old"}), ({"isActive": "false"}, {"old"}), ({"hasAgent": "false"}, {"foxray-sales", "edevs", "old"}), @@ -407,126 +401,42 @@ class ChannelListTests(ChannelApiTestCase): self.assertEqual(count_queries(), baseline) -class ChannelScopeTests(ChannelApiTestCase): - """§5.2 — department-scoped доступ.""" +class ChannelGroupTests(ChannelApiTestCase): + """Канал закрепляется за настраиваемой группой (ADR-HUB-0043 §3).""" def setUp(self) -> None: super().setUp() - self.sales_channel = _make_channel( + self.channel = _make_channel( self.organization, code="foxray-sales", name="FoxRay — продажи", - department=self.sales, product=self.product, - ) - self.support_channel = _make_channel( - self.organization, code="foxray-support", name="FoxRay — поддержка", - department=self.support, product=self.product, - ) - self.orphan = _make_channel(self.organization, code="edevs", name="Edevs — сайт") - - user = HumanUser.objects.create_user( - email="sales.lead@edevs.tech", password="Operator-Local-2026" - ) - self.employee = OrganizationMembership.objects.create( - user=user, - organization=self.organization, - role=EmployeeRole.EMPLOYEE, - position_title="Руководитель продаж", - primary_department=self.sales, - ) - profile = AccessProfile.objects.create( - organization=self.organization, name="Channel manager" - ) - for code in ("channels.view", "channels.manage"): - AccessProfileCapability.objects.create( - access_profile=profile, capability_code=code - ) - owner = self.organization.memberships.get(role=EmployeeRole.OWNER) - EmployeeAccessAssignment.objects.create( - employee=self.employee, - access_profile=profile, - scope_type=ScopeType.DEPARTMENT, - department=self.sales, - assigned_by=owner, - ) - self.scoped = APIClient() - self.scoped.login( - username="sales.lead@edevs.tech", password="Operator-Local-2026" + group=self.operators, product=self.product, ) - def test_list_hides_other_departments_and_orphan_channels(self) -> None: - response = self.scoped.get("/api/v1/channels/") + def test_payload_exposes_group_reference(self) -> None: + response = self.client.get(f"/api/v1/channels/{self.channel.id}/") - codes = {item["code"] for item in response.json()["items"]} - self.assertEqual(codes, {"foxray-sales"}) + channel = response.json()["channel"] + self.assertEqual(channel["groupId"], self.operators.id) + self.assertEqual(channel["groupName"], "Операторы") - def test_channel_outside_scope_is_not_found(self) -> None: - for channel in (self.support_channel, self.orphan): - with self.subTest(code=channel.code): - response = self.scoped.get(f"/api/v1/channels/{channel.id}/") - self.assertEqual(response.status_code, 404) + def test_manager_moves_channel_between_groups_and_detaches(self) -> None: + moved = self.patch_channel(self.channel.id, groupId=self.support_group.id) + self.assertEqual(moved.status_code, 200) + self.channel.refresh_from_db() + self.assertEqual(self.channel.group_id, self.support_group.id) - def test_scoped_manager_renames_own_channel(self) -> None: - response = self.scoped.patch( - f"/api/v1/channels/{self.sales_channel.id}/", - data=json.dumps({"name": "FoxRay — продажи RU"}), - content_type="application/json", - ) - self.assertEqual(response.status_code, 200) + detached = self.patch_channel(self.channel.id, groupId=None) + self.assertEqual(detached.status_code, 200) + self.channel.refresh_from_db() + self.assertIsNone(self.channel.group_id) - def test_scoped_manager_cannot_change_product_policy_or_status(self) -> None: - for body in ( - {"productId": None}, - {"isActive": False}, - {"policy": {"allowCheckoutActions": True}}, - ): - with self.subTest(body=body): - response = self.scoped.patch( - f"/api/v1/channels/{self.sales_channel.id}/", - data=json.dumps(body), - content_type="application/json", - ) - self.assertEqual(response.status_code, 403) - self.sales_channel.refresh_from_db() - self.assertEqual(self.sales_channel.product_id, self.product.id) - self.assertTrue(self.sales_channel.is_active) + def test_foreign_organization_group_is_rejected(self) -> None: + other = Organization.objects.create(slug="other-group-org", name="Other") + foreign = EmployeeGroup.objects.create(organization=other, name="Чужая") + response = self.patch_channel(self.channel.id, groupId=foreign.id) - def test_scoped_manager_cannot_detach_department(self) -> None: - # Снятие отдела вывело бы канал из собственной видимости сотрудника. - response = self.scoped.patch( - f"/api/v1/channels/{self.sales_channel.id}/", - data=json.dumps({"departmentId": None}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 403) - self.sales_channel.refresh_from_db() - self.assertEqual(self.sales_channel.department_id, self.sales.id) - - def test_scoped_manager_cannot_move_channel_to_foreign_department(self) -> None: - response = self.scoped.patch( - f"/api/v1/channels/{self.sales_channel.id}/", - data=json.dumps({"departmentId": self.support.id}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 403) - - def test_scoped_manager_cannot_create_or_delete(self) -> None: - created = self.scoped.post( - "/api/v1/channels/", - data=json.dumps( - { - "code": "new-line", - "name": "Новая линия", - "departmentId": self.sales.id, - "policy": OPERATOR_POLICY, - } - ), - content_type="application/json", - ) - self.assertEqual(created.status_code, 403) - - deleted = self.scoped.delete(f"/api/v1/channels/{self.sales_channel.id}/") - self.assertEqual(deleted.status_code, 403) + self.assertEqual(response.status_code, 400) + self.channel.refresh_from_db() + self.assertEqual(self.channel.group_id, self.operators.id) class ChannelPermissionTests(ChannelApiTestCase): @@ -543,7 +453,6 @@ class ChannelPermissionTests(ChannelApiTestCase): organization=self.organization, role=EmployeeRole.EMPLOYEE, position_title="Оператор", - primary_department=None, ) self.operator_client = APIClient() self.operator_client.login( diff --git a/apps/backend/hub_platform/channels/views.py b/apps/backend/hub_platform/channels/views.py index 507ba95..2814803 100644 --- a/apps/backend/hub_platform/channels/views.py +++ b/apps/backend/hub_platform/channels/views.py @@ -7,7 +7,6 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from hub_platform.ai.knowledge_conflicts import KnowledgeScopeConflict from hub_platform.ai.models import AIAgentStatus from hub_platform.ai.provider.base import ProviderError from hub_platform.api.permissions import HasCapability @@ -55,7 +54,6 @@ def _load(request: Request, channel_id: int) -> Channel: class ChannelListView(APIView): permission_classes = [HasCapability] - # Department-scoped доступ обязан проходить вход: срез считает селектор. required_capabilities = {"GET": "channels.view", "POST": "channels.manage"} def get(self, request: Request) -> Response: @@ -73,7 +71,7 @@ class ChannelListView(APIView): context=request.tenant_context, code=data.get("code"), name=data.get("name"), - department_id=data.get("departmentId"), + group_id=data.get("groupId"), product_id=data.get("productId"), policy=policy, connection_ids=connection_ids, @@ -117,8 +115,6 @@ class ChannelDetailView(APIView): channel = services.update_channel( context=request.tenant_context, channel=channel, update=update ) - except KnowledgeScopeConflict as error: - return Response(error.payload(), status=409) except services.ChannelHasReferences as error: return Response(error.payload(), status=409) except PolicyInvariantError as error: @@ -130,7 +126,7 @@ class ChannelDetailView(APIView): after = channel_payload(channel) diff = { key: {"from": before[key], "to": after[key]} - for key in ("name", "departmentId", "product", "isActive", "policy") + for key in ("name", "groupId", "product", "isActive", "policy") if before[key] != after[key] } if diff: diff --git a/apps/backend/hub_platform/conversations/clients.py b/apps/backend/hub_platform/conversations/clients.py index 502ba2f..655ac9c 100644 --- a/apps/backend/hub_platform/conversations/clients.py +++ b/apps/backend/hub_platform/conversations/clients.py @@ -46,17 +46,11 @@ def _mode(latest: Conversation) -> str: return "wait" -def clients_overview( - organization_id: int, department_ids: set[int] | None = None -) -> list[dict]: +def clients_overview(organization_id: int) -> list[dict]: conversation_qs = Conversation.objects.select_related( "channel", "channel__product", "connection" ).order_by("-last_activity_at") - if department_ids is not None: - conversation_qs = conversation_qs.filter(channel__department_id__in=department_ids) identity_qs = ConnectionIdentity.objects.select_related("connection") - if department_ids is not None: - identity_qs = identity_qs.filter(connection__channel__department_id__in=department_ids) contacts = Contact.objects.filter(organization_id=organization_id).prefetch_related( Prefetch( "conversations", @@ -114,17 +108,11 @@ def _dialog_status(conversation: Conversation) -> str: return {"closed": "Закрыт", "operator": "Оператор", "ai": "AI", "wait": "Ждёт оператора"}[_mode(conversation)] -def client_detail( - organization_id: int, - contact_id: int, - department_ids: set[int] | None = None, -) -> dict: +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") - if department_ids is not None: - conversation_qs = conversation_qs.filter(channel__department_id__in=department_ids) conversations = list(conversation_qs.order_by("-last_activity_at")) if not conversations: raise Contact.DoesNotExist @@ -156,10 +144,6 @@ def client_detail( ) identity_qs = contact.identities.select_related("connection") - if department_ids is not None: - identity_qs = identity_qs.filter( - connection__channel__department_id__in=department_ids - ) identities = [ { "provider": identity.connection.provider, @@ -185,8 +169,7 @@ def client_detail( conversation_ids = [str(conversation.id) for conversation in conversations] audit = [] audit_scope = Q(object_type="Conversation", object_id__in=conversation_ids) - if department_ids is None: - audit_scope |= Q(object_type="Contact", object_id=str(contact_id)) + audit_scope |= Q(object_type="Contact", object_id=str(contact_id)) audit_qs = ( AuditEvent.objects.filter(organization_id=organization_id) .filter(audit_scope) diff --git a/apps/backend/hub_platform/conversations/command.py b/apps/backend/hub_platform/conversations/command.py index 88247b3..ce47c16 100644 --- a/apps/backend/hub_platform/conversations/command.py +++ b/apps/backend/hub_platform/conversations/command.py @@ -1,8 +1,8 @@ """Командный центр: реальная сводка уровня компании (без выдуманных чисел). -Метрики диалогов по отделам; коммерция удалена вместе с доменом продаж -(ADR-HUB-0041). «Требует внимания» и состояние интеграций — из реальных -данных; расходы AI — из LlmInvocation. +Отделы упразднены (ADR-HUB-0043): карточки строятся по настраиваемым группам +организации плюс блок «Без группы». «Требует внимания» и состояние интеграций — +из реальных данных; расходы AI — из LlmInvocation. """ from __future__ import annotations @@ -14,12 +14,10 @@ from django.utils import timezone from hub_platform.ai.models import AIAgent, LlmInvocation from hub_platform.conversations.models import Conversation, ControlMode, LifecycleState from hub_platform.conversations.stats import _ACTIVE_WINDOW, _window -from hub_platform.identity.models import Department, DepartmentStatus, OrganizationMembership +from hub_platform.identity.group_models import EmployeeGroup from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationStatus from hub_platform.tenancy.context import TenantContext -_DEPT_ROUTE = {"sales": "salesDialogs", "support": "supportOverview"} - def _dialog_block(open_qs, now) -> dict: return { @@ -43,31 +41,50 @@ def command_center_overview(context: TenantContext, period: str) -> dict: open_qs = Conversation.objects.filter(organization_id=organization_id, lifecycle=LifecycleState.OPEN) - employees_by_dept = dict( - OrganizationMembership.objects.filter( - organization_id=organization_id, blocked_at__isnull=True, primary_department__isnull=False - ) - .values_list("primary_department_id") - .annotate(c=Count("id")) + groups = list( + EmployeeGroup.objects.filter(organization_id=organization_id) + .annotate(member_count=Count("member_links", distinct=True)) + .order_by("name") ) - agents_by_dept = dict( - AIAgent.objects.filter(channel__organization_id=organization_id, status="ACTIVE", channel__department__isnull=False) - .values_list("channel__department_id") + agents_by_group = dict( + AIAgent.objects.filter( + channel__organization_id=organization_id, + status="ACTIVE", + channel__group__isnull=False, + ) + .values_list("channel__group_id") .annotate(c=Count("id")) ) - departments = [] - for department in Department.objects.filter(organization_id=organization_id, status=DepartmentStatus.ACTIVE).order_by("created_at"): - dept_open = open_qs.filter(channel__department=department) - block = { - "code": department.code, - "name": department.name, - "route": _DEPT_ROUTE.get(department.code, "command"), - "employees": employees_by_dept.get(department.id, 0), - "aiAgents": agents_by_dept.get(department.id, 0), - "dialogs": _dialog_block(dept_open, now), - } - departments.append(block) + cards = [] + for group in groups: + group_open = open_qs.filter(group=group) + cards.append( + { + "code": str(group.id), + "name": group.name, + "route": "salesDialogs", + "employees": group.member_count, + "aiAgents": agents_by_group.get(group.id, 0), + "dialogs": _dialog_block(group_open, now), + } + ) + ungrouped_open = open_qs.filter(group__isnull=True) + if not groups or ungrouped_open.exists(): + cards.append( + { + "code": "none", + "name": "Без группы", + "route": "salesDialogs", + "employees": 0, + "aiAgents": AIAgent.objects.filter( + channel__organization_id=organization_id, + status="ACTIVE", + channel__group__isnull=True, + ).count(), + "dialogs": _dialog_block(ungrouped_open, now), + } + ) # «Требует внимания»: очередь диалогов + ошибки интеграций. attention: list[dict] = [] @@ -92,28 +109,28 @@ def command_center_overview(context: TenantContext, period: str) -> dict: error_count = 0 for integration in Integration.objects.filter(organization_id=organization_id): if integration.kind == IntegrationKind.LLM_PROVIDER: - group = "AI-провайдер" + group_label = "AI-провайдер" elif integration.config.get("purpose") == "notifications": - group = "Бот уведомлений" + group_label = "Бот уведомлений" else: - group = "Канал" + group_label = "Канал" if integration.status == IntegrationStatus.ERROR: error_count += 1 attention.append( { "kind": "integration", "title": f"Ошибка интеграции · {integration.name}", - "meta": group, + "meta": group_label, "minutes": _minutes_since(integration.last_checked_at or integration.updated_at, now), } ) - integrations.append({"name": integration.name, "group": group, "status": integration.status}) + integrations.append({"name": integration.name, "group": group_label, "status": integration.status}) invocations = LlmInvocation.objects.filter(channel__organization_id=organization_id, created_at__gte=start) ai_totals = invocations.aggregate(cost=Sum("cost_micros"), tokens=Sum("total_tokens")) period_dialogs = Conversation.objects.filter(organization_id=organization_id, created_at__gte=start).count() - total_waiting = sum(d["dialogs"]["waiting"] for d in departments) + total_waiting = sum(card["dialogs"]["waiting"] for card in cards) if error_count: status = "critical" elif total_waiting: @@ -126,10 +143,10 @@ def command_center_overview(context: TenantContext, period: str) -> dict: "generatedAt": now.isoformat(), "company": { "status": status, - "departments": len(departments), + "departments": len(cards), "openDialogs": open_qs.count(), }, - "departments": departments, + "departments": cards, "attention": attention, "integrations": integrations, "ai": { diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py index a3c21c8..68bf59c 100644 --- a/apps/backend/hub_platform/conversations/ingest.py +++ b/apps/backend/hub_platform/conversations/ingest.py @@ -131,6 +131,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: conversation = Conversation.objects.create( organization=channel.organization, channel=channel, + # Диалог наследует группу канала при создании (ADR-HUB-0043 §3). + group=channel.group, connection=integration, contact=contact, external_chat_id=inbound.chat_id, @@ -181,7 +183,6 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: if is_new: notify( context=context, - department=channel.department, type=NotificationType.DIALOG_WAITING, audience=NotificationAudience.OPERATORS, title=f"Новый диалог · {channel.name}", @@ -196,7 +197,6 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: operator = conversation.assigned_operator notify( context=context, - department=channel.department, type=NotificationType.DIALOG_NEW_MESSAGE, audience=NotificationAudience.USER if operator else NotificationAudience.OPERATORS, recipient_user=operator, @@ -244,7 +244,6 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback) notify( context=context, - department=channel.department, type=NotificationType.DIALOG_WAITING, audience=NotificationAudience.OPERATORS, title=f"Нужен оператор · {contact.name or 'Гость'}", @@ -285,7 +284,6 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI передал диалог оператору") notify( context=context, - department=channel.department, type=NotificationType.DIALOG_WAITING, audience=NotificationAudience.OPERATORS, title=f"AI передал диалог · {contact.name or 'Гость'}", diff --git a/apps/backend/hub_platform/conversations/migrations/0010_conversation_group.py b/apps/backend/hub_platform/conversations/migrations/0010_conversation_group.py new file mode 100644 index 0000000..2a8757e --- /dev/null +++ b/apps/backend/hub_platform/conversations/migrations/0010_conversation_group.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0009_contact_avatar_url'), + ('identity', '0020_employeegroup_employeegroupmember_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='conversation', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversations', to='identity.employeegroup'), + ), + ] diff --git a/apps/backend/hub_platform/conversations/models.py b/apps/backend/hub_platform/conversations/models.py index 6ca41b3..e1b2c20 100644 --- a/apps/backend/hub_platform/conversations/models.py +++ b/apps/backend/hub_platform/conversations/models.py @@ -86,6 +86,16 @@ class Conversation(models.Model): lifecycle = models.CharField(max_length=16, choices=LifecycleState.choices, default=LifecycleState.OPEN) control_mode = models.CharField(max_length=16, choices=ControlMode.choices, default=ControlMode.AI) expected_responder = models.CharField(max_length=16, choices=ExpectedResponder.choices, default=ExpectedResponder.AI) + # Группа видимости (ADR-HUB-0043): наследуется от group агента/канала при + # создании, переносится вручную. NULL — диалог виден всем сотрудникам. + group = models.ForeignKey( + "identity.EmployeeGroup", + on_delete=models.SET_NULL, + related_name="conversations", + null=True, + blank=True, + ) + # «Ответственный» (ADR-HUB-0043): видит диалог независимо от групп. assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations") previous_conversation = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="+") created_at = models.DateTimeField(auto_now_add=True) diff --git a/apps/backend/hub_platform/conversations/reporting_views.py b/apps/backend/hub_platform/conversations/reporting_views.py index 1969626..e779f98 100644 --- a/apps/backend/hub_platform/conversations/reporting_views.py +++ b/apps/backend/hub_platform/conversations/reporting_views.py @@ -6,7 +6,6 @@ from hub_platform.conversations.command import command_center_overview from hub_platform.conversations.models import Contact from hub_platform.conversations.stats import sales_overview_stats from hub_platform.conversations.view_base import ConversationViewBase -from hub_platform.identity.policy import accessible_department_ids class ConversationStatsView(ConversationViewBase): @@ -14,17 +13,11 @@ class ConversationStatsView(ConversationViewBase): period = request.query_params.get("period", "today") if period not in ("today", "d7", "d30"): period = "today" - department_ids = accessible_department_ids( - request.tenant_context.membership, self.required_capability - ) - return Response( - sales_overview_stats(request.tenant_context, period, department_ids) - ) + return Response(sales_overview_stats(request.tenant_context, period)) class CommandOverviewView(ConversationViewBase): required_capability = "company.view" - require_organization_scope = True def get(self, request: Request) -> Response: period = request.query_params.get("period", "today") @@ -37,12 +30,7 @@ class ClientsView(ConversationViewBase): required_capability = "customers.view" def get(self, request: Request) -> Response: - department_ids = accessible_department_ids( - request.tenant_context.membership, self.required_capability - ) - return Response( - {"items": clients_overview(self._org(request).id, department_ids)} - ) + return Response({"items": clients_overview(self._org(request).id)}) class ClientDetailView(ConversationViewBase): @@ -50,15 +38,6 @@ class ClientDetailView(ConversationViewBase): def get(self, request: Request, contact_id: int) -> Response: try: - department_ids = accessible_department_ids( - request.tenant_context.membership, self.required_capability - ) - return Response( - { - "client": client_detail( - self._org(request).id, contact_id, department_ids - ) - } - ) + return Response({"client": client_detail(self._org(request).id, contact_id)}) except Contact.DoesNotExist: return Response({"detail": "Клиент не найден"}, status=404) diff --git a/apps/backend/hub_platform/conversations/selectors.py b/apps/backend/hub_platform/conversations/selectors.py index 7b14709..7fb567d 100644 --- a/apps/backend/hub_platform/conversations/selectors.py +++ b/apps/backend/hub_platform/conversations/selectors.py @@ -1,6 +1,7 @@ -from django.db.models import F, Max, QuerySet +from django.db.models import F, Max, Q, QuerySet from hub_platform.conversations.models import Conversation +from hub_platform.identity.policy import conversation_visibility from hub_platform.tenancy.context import TenantContext @@ -14,6 +15,7 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]: "connection", "assigned_operator", "support_identity_snapshot", + "group", ) # Инбокс сортируется по времени последнего сообщения (а не по служебной # активности вроде claim/takeover); fallback — last_activity_at для @@ -23,5 +25,40 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]: ) +def apply_conversation_visibility( + queryset: QuerySet[Conversation], context: TenantContext +) -> QuerySet[Conversation]: + """Видимость диалогов (ADR-HUB-0043 §4): OWNER/ADMIN — все; сотрудник — + диалоги своих групп + без группы + где он ответственный.""" + scope = conversation_visibility(context.membership) + if scope is None: + return queryset + if scope.get("none"): + return queryset.none() + return queryset.filter( + Q(group__isnull=True) + | Q(group_id__in=scope["group_ids"]) + | Q(assigned_operator_id=scope["user_id"]) + ) + + +def conversation_is_visible(*, actor, conversation: Conversation) -> bool: + """Точечная проверка той же видимости для уже загруженного диалога.""" + scope = conversation_visibility(actor) + if scope is None: + return True + if scope.get("none"): + return False + return ( + conversation.group_id is None + or conversation.group_id in scope["group_ids"] + or conversation.assigned_operator_id == scope["user_id"] + ) + + +def visible_conversations_for(context: TenantContext) -> QuerySet[Conversation]: + return apply_conversation_visibility(conversations_for_context(context), context) + + def conversation_for_context(*, context: TenantContext, conversation_id: int) -> Conversation: - return conversations_for_context(context).get(id=conversation_id) + return visible_conversations_for(context).get(id=conversation_id) diff --git a/apps/backend/hub_platform/conversations/stats.py b/apps/backend/hub_platform/conversations/stats.py index 32d6ddf..a0dc661 100644 --- a/apps/backend/hub_platform/conversations/stats.py +++ b/apps/backend/hub_platform/conversations/stats.py @@ -1,4 +1,4 @@ -"""Real department overview aggregates (no fabricated numbers). +"""Real group overview aggregates (no fabricated numbers). Commerce metrics were removed with the sales domain (ADR-HUB-0041). Everything here is derived from real conversations, messages and LLM usage. @@ -42,11 +42,8 @@ def _chart( period: str, start: datetime, now: datetime, - department_ids: set[int] | None, ) -> dict: qs = Conversation.objects.filter(organization_id=org_id, created_at__gte=start) - if department_ids is not None: - qs = qs.filter(channel__department_id__in=department_ids) if period == "today": rows = qs.annotate(b=TruncHour("created_at")).values("b").annotate(c=Count("id")) counts = {row["b"].astimezone(now.tzinfo).hour: row["c"] for row in rows} @@ -70,28 +67,19 @@ def _ai_cost( org_id: int, start: datetime, end: datetime | None = None, - department_ids: set[int] | None = None, ) -> int: qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start) - if department_ids is not None: - qs = qs.filter(channel__department_id__in=department_ids) if end is not None: qs = qs.filter(created_at__lt=end) return qs.aggregate(total=Sum("cost_micros"))["total"] or 0 -def sales_overview_stats( - context, - period: str, - department_ids: set[int] | None = None, -) -> dict: +def sales_overview_stats(context, period: str) -> dict: organization_id = context.organization_id now = timezone.now() start, prev_start = _window(period, now) open_qs = Conversation.objects.filter(organization_id=organization_id, lifecycle=LifecycleState.OPEN) - if department_ids is not None: - open_qs = open_qs.filter(channel__department_id__in=department_ids) open_dialogs = open_qs.count() # «Ждут оператора» = очередь: диалоги, которые никто не взял (PAUSED). # Взятые оператором (HUMAN), но ещё без ответа, очередью не считаются — @@ -106,34 +94,18 @@ def sales_overview_stats( } period_qs = Conversation.objects.filter(organization_id=organization_id, created_at__gte=start) - if department_ids is not None: - period_qs = period_qs.filter(channel__department_id__in=department_ids) dialogs = period_qs.count() period_block = { "dialogs": dialogs, "dialogsPrev": Conversation.objects.filter( organization_id=organization_id, created_at__gte=prev_start, created_at__lt=start - ).filter( - **( - {"channel__department_id__in": department_ids} - if department_ids is not None - else {} - ) ).count(), "messages": Message.objects.filter( conversation__organization_id=organization_id, created_at__gte=start, - ).filter( - **( - {"conversation__channel__department_id__in": department_ids} - if department_ids is not None - else {} - ) ).count(), - "aiCostMicros": _ai_cost(organization_id, start, department_ids=department_ids), - "aiCostPrevMicros": _ai_cost( - organization_id, prev_start, start, department_ids=department_ids - ), + "aiCostMicros": _ai_cost(organization_id, start), + "aiCostPrevMicros": _ai_cost(organization_id, prev_start, start), } open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id"))) @@ -142,8 +114,6 @@ def sales_overview_stats( by_channel: list[dict] = [] by_product: dict[str, dict] = {} channels = channels_in_organization(context) - if department_ids is not None: - channels = channels.filter(department_id__in=department_ids) for channel in channels: open_count = open_by_channel.get(channel.id, 0) period_count = period_by_channel.get(channel.id, 0) @@ -187,6 +157,6 @@ def sales_overview_stats( "period": period_block, "byChannel": by_channel, "byProduct": list(by_product.values()), - "chart": _chart(organization_id, period, start, now, department_ids), + "chart": _chart(organization_id, period, start, now), "problems": problems, } diff --git a/apps/backend/hub_platform/conversations/test_authorization.py b/apps/backend/hub_platform/conversations/test_authorization.py index c83cecf..78b1245 100644 --- a/apps/backend/hub_platform/conversations/test_authorization.py +++ b/apps/backend/hub_platform/conversations/test_authorization.py @@ -3,11 +3,8 @@ from hub_platform.testing import TenantAPIClient as APIClient from hub_platform.channels.models import Channel from hub_platform.conversations.models import Contact, Conversation +from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - Department, - EmployeeAccessAssignment, EmployeeRole, HumanUser, Organization, @@ -15,72 +12,57 @@ from hub_platform.identity.models import ( ) -class ConversationAuthorizationTests(TestCase): +class ConversationVisibilityTests(TestCase): + """Видимость диалогов по группам (ADR-HUB-0043 §4): диалоги групп сотрудника + + диалоги без группы + назначенные ему; OWNER/ADMIN видят всё.""" + def setUp(self) -> None: self.organization = Organization.objects.create(name="Example", slug="conversation-auth") - self.sales = Department.objects.create( - organization=self.organization, code="sales", name="Sales" + self.operators = EmployeeGroup.objects.create( + organization=self.organization, name="Операторы" ) - self.support = Department.objects.create( - organization=self.organization, code="support", name="Support" + self.support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" ) self.owner = self._employee("owner@conversation.test", EmployeeRole.OWNER) - self.employee = self._employee( - "employee@conversation.test", EmployeeRole.EMPLOYEE, self.sales + self.employee = self._employee("employee@conversation.test", EmployeeRole.EMPLOYEE) + self.outsider = self._employee("outsider@conversation.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.operators, employee=self.employee ) - self.unassigned = self._employee( - "unassigned@conversation.test", EmployeeRole.EMPLOYEE, self.support - ) - self.sales_conversation = self._conversation(self.sales, "sales-channel") + self.operators_conversation = self._conversation(self.operators, "operators-channel") self.support_conversation = self._conversation(self.support, "support-channel") - - profile = AccessProfile.objects.create( - organization=self.organization, name="Sales conversations" - ) - for code in ("conversations.view", "conversations.operate"): - AccessProfileCapability.objects.create( - access_profile=profile, capability_code=code - ) - EmployeeAccessAssignment.objects.create( - employee=self.employee, - access_profile=profile, - scope_type="DEPARTMENT", - department=self.sales, - assigned_by=self.owner, - ) + self.shared_conversation = self._conversation(None, "shared-channel") self.client = APIClient() self.client.force_authenticate(self.employee.user) - def _employee( - self, email: str, role: str, department: Department | None = None - ) -> OrganizationMembership: + def _employee(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", - primary_department=department, ) - def _conversation(self, department: Department, code: str) -> Conversation: + def _conversation(self, group: EmployeeGroup | None, code: str) -> Conversation: channel = Channel.objects.create( organization=self.organization, - department=department, + group=group, code=code, name=code, ) contact = Contact.objects.create(organization=self.organization, name=code) return Conversation.objects.create( - organization=self.organization, channel=channel, contact=contact + organization=self.organization, channel=channel, group=group, contact=contact ) - def test_list_and_direct_id_use_same_department_scope(self) -> None: + def test_employee_sees_own_group_and_ungrouped_dialogs(self) -> None: response = self.client.get("/api/v1/conversations/") self.assertEqual(response.status_code, 200) self.assertEqual( - [item["id"] for item in response.json()["items"]], - [self.sales_conversation.id], + {item["id"] for item in response.json()["items"]}, + {self.operators_conversation.id, self.shared_conversation.id}, ) detail = self.client.get( @@ -92,7 +74,67 @@ class ConversationAuthorizationTests(TestCase): ) self.assertEqual(action.status_code, 404) - def test_primary_department_alone_does_not_open_list(self) -> None: - self.client.force_authenticate(self.unassigned.user) + def test_assignee_sees_foreign_group_dialog(self) -> None: + self.support_conversation.assigned_operator = self.employee.user + self.support_conversation.save(update_fields=["assigned_operator"]) response = self.client.get("/api/v1/conversations/") - self.assertEqual(response.status_code, 403) + self.assertIn( + self.support_conversation.id, + {item["id"] for item in response.json()["items"]}, + ) + detail = self.client.get( + f"/api/v1/conversations/{self.support_conversation.id}/" + ) + self.assertEqual(detail.status_code, 200) + + def test_employee_without_groups_sees_only_ungrouped(self) -> None: + self.client.force_authenticate(self.outsider.user) + response = self.client.get("/api/v1/conversations/") + self.assertEqual(response.status_code, 200) + self.assertEqual( + {item["id"] for item in response.json()["items"]}, + {self.shared_conversation.id}, + ) + + def test_owner_sees_everything(self) -> None: + self.client.force_authenticate(self.owner.user) + response = self.client.get("/api/v1/conversations/") + self.assertEqual( + {item["id"] for item in response.json()["items"]}, + { + self.operators_conversation.id, + self.support_conversation.id, + self.shared_conversation.id, + }, + ) + + def test_move_dialog_to_group_and_assign_responsible(self) -> None: + self.client.force_authenticate(self.owner.user) + moved = self.client.post( + f"/api/v1/conversations/{self.shared_conversation.id}/group/", + data={"groupId": self.support.id}, + format="json", + ) + self.assertEqual(moved.status_code, 200) + self.shared_conversation.refresh_from_db() + self.assertEqual(self.shared_conversation.group_id, self.support.id) + + assigned = self.client.post( + f"/api/v1/conversations/{self.shared_conversation.id}/assignee/", + data={"userId": self.employee.user_id}, + format="json", + ) + self.assertEqual(assigned.status_code, 200) + self.shared_conversation.refresh_from_db() + self.assertEqual( + self.shared_conversation.assigned_operator_id, self.employee.user_id + ) + + cleared = self.client.post( + f"/api/v1/conversations/{self.shared_conversation.id}/group/", + data={"groupId": None}, + format="json", + ) + self.assertEqual(cleared.status_code, 200) + self.shared_conversation.refresh_from_db() + self.assertIsNone(self.shared_conversation.group_id) diff --git a/apps/backend/hub_platform/conversations/tests.py b/apps/backend/hub_platform/conversations/tests.py index 97698f9..9b63f82 100644 --- a/apps/backend/hub_platform/conversations/tests.py +++ b/apps/backend/hub_platform/conversations/tests.py @@ -306,35 +306,38 @@ class ConversationReadTests(TestCase): class CommandOverviewTests(TestCase): - """Сводка командного центра: оба отдела, реальные метрики, доступ OWNER.""" + """Сводка командного центра: карточки по группам, реальные метрики, доступ OWNER.""" def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") + self.operators = self.organization.employee_groups.get(name="Операторы") self.channel = Channel.objects.create( organization=self.organization, code="foxray-sales", name="FoxRay — продажи", - department=self.organization.departments.get(code="sales"), + group=self.operators, ) self.integration = _messenger_connection(self.channel) contact = Contact.objects.create(organization=self.organization, name="Иван") Conversation.objects.create( organization=self.organization, channel=self.channel, connection=self.integration, - contact=contact, control_mode=ControlMode.PAUSED, + contact=contact, group=self.operators, control_mode=ControlMode.PAUSED, ) self.client = APIClient() self.client.login(username="owner@edevs.tech", password="temporary-password") - def test_overview_returns_departments_and_attention(self) -> None: + def test_overview_returns_groups_and_attention(self) -> None: response = self.client.get("/api/v1/conversations/command-overview/?period=today") self.assertEqual(response.status_code, 200) payload = response.json() codes = [d["code"] for d in payload["departments"]] - self.assertIn("sales", codes) - self.assertIn("support", codes) - sales = next(d for d in payload["departments"] if d["code"] == "sales") - self.assertEqual(sales["dialogs"]["open"], 1) - self.assertEqual(sales["dialogs"]["waiting"], 1) - self.assertNotIn("commerce", sales) + self.assertIn(str(self.operators.id), codes) + operators = next( + d for d in payload["departments"] if d["code"] == str(self.operators.id) + ) + self.assertEqual(operators["name"], "Операторы") + self.assertEqual(operators["dialogs"]["open"], 1) + self.assertEqual(operators["dialogs"]["waiting"], 1) + self.assertNotIn("commerce", operators) self.assertEqual(payload["company"]["status"], "attention") self.assertTrue(any(item["kind"] == "dialog" for item in payload["attention"])) # Интеграции отражены с группой. diff --git a/apps/backend/hub_platform/conversations/urls.py b/apps/backend/hub_platform/conversations/urls.py index 3f3cedd..fdd033d 100644 --- a/apps/backend/hub_platform/conversations/urls.py +++ b/apps/backend/hub_platform/conversations/urls.py @@ -16,4 +16,6 @@ urlpatterns = [ path("/request-contact/", views.ConversationRequestContactView.as_view(), name="conversation-request-contact"), path("/close/", views.ConversationCloseView.as_view(), name="conversation-close"), path("/spam/", views.ConversationSpamView.as_view(), name="conversation-spam"), + path("/group/", views.ConversationGroupView.as_view(), name="conversation-group"), + path("/assignee/", views.ConversationAssigneeView.as_view(), name="conversation-assignee"), ] diff --git a/apps/backend/hub_platform/conversations/views.py b/apps/backend/hub_platform/conversations/views.py index 2cb191f..60d01d6 100644 --- a/apps/backend/hub_platform/conversations/views.py +++ b/apps/backend/hub_platform/conversations/views.py @@ -7,7 +7,7 @@ from hub_platform.conversations.models import ( ConversationRead, LifecycleState, ) -from hub_platform.conversations.selectors import conversations_for_context +from hub_platform.conversations.selectors import visible_conversations_for from hub_platform.conversations.serializers import conversation_payload, message_payload from hub_platform.conversations.services import ( ClaimError, @@ -20,22 +20,19 @@ from hub_platform.conversations.services import ( return_to_queue, ) from hub_platform.conversations.view_base import ConversationViewBase -from hub_platform.identity.policy import ( - ResourceScope, - accessible_department_ids, - authorize, -) +from hub_platform.identity.group_models import EmployeeGroup +from hub_platform.identity.models import OrganizationMembership +from hub_platform.identity.policy import ResourceScope, authorize class ConversationListView(ConversationViewBase): def get(self, request: Request) -> Response: - items = conversations_for_context(request.tenant_context) - department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) - if department_ids is not None: - items = items.filter(channel__department_id__in=department_ids) - department = request.query_params.get("department") - if department: - items = items.filter(channel__department__code=department) + items = visible_conversations_for(request.tenant_context) + group = request.query_params.get("group") + if group == "none": + items = items.filter(group__isnull=True) + elif group: + items = items.filter(group_id=group) lifecycle = request.query_params.get("lifecycle") if lifecycle: items = items.filter(lifecycle=lifecycle) @@ -270,3 +267,70 @@ class ConversationSpamView(ConversationViewBase): ) } ) + + +class ConversationGroupView(ConversationViewBase): + """Перенос диалога в группу и снятие группы (ADR-HUB-0043 §3).""" + + required_capability = "conversations.operate" + + def post(self, request: Request, conversation_id: int) -> Response: + try: + conversation = self._conversation(request, conversation_id, self.required_capability) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + group_id = request.data.get("groupId") + group = None + if group_id is not None: + group = EmployeeGroup.objects.filter( + organization_id=conversation.organization_id, id=group_id + ).first() + if group is None: + return Response({"detail": "Группа не найдена"}, status=400) + conversation.group = group + conversation.save(update_fields=["group"]) + self._audit(request, "group_changed", conversation) + return Response( + { + "conversation": conversation_payload( + conversation, viewer_id=request.user.id + ) + } + ) + + +class ConversationAssigneeView(ConversationViewBase): + """Назначение и переназначение ответственного (ADR-HUB-0043 §3).""" + + required_capability = "conversations.operate" + + def post(self, request: Request, conversation_id: int) -> Response: + try: + conversation = self._conversation(request, conversation_id, self.required_capability) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + user_id = request.data.get("userId") + assignee = None + if user_id is not None: + membership = ( + OrganizationMembership.objects.select_related("user") + .filter( + organization_id=conversation.organization_id, + user_id=user_id, + blocked_at__isnull=True, + ) + .first() + ) + if membership is None: + return Response({"detail": "Сотрудник не найден"}, status=400) + assignee = membership.user + conversation.assigned_operator = assignee + conversation.save(update_fields=["assigned_operator"]) + self._audit(request, "assignee_changed", conversation) + return Response( + { + "conversation": conversation_payload( + conversation, viewer_id=request.user.id + ) + } + ) diff --git a/apps/backend/hub_platform/identity/access_defaults.py b/apps/backend/hub_platform/identity/access_defaults.py deleted file mode 100644 index 20e1ef8..0000000 --- a/apps/backend/hub_platform/identity/access_defaults.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from hub_platform.identity.capabilities import ScopeType -from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - Department, - EmployeeAccessAssignment, - OrganizationMembership, -) - - -SYSTEM_PROFILE_CAPABILITIES = { - "Sales operator": ( - "conversations.view", - "conversations.operate", - "conversations.call", - "customers.view", - "customers.manage", - "products.view", - ), - "Support operator": ( - "conversations.view", - "conversations.operate", - "conversations.call", - "customers.view", - "products.view", - "support.view", - "support.operate", - ), -} - - -def ensure_system_assignment( - *, - employee: OrganizationMembership, - assigned_by: OrganizationMembership, - department: Department, - profile_name: str, -) -> EmployeeAccessAssignment: - capabilities = SYSTEM_PROFILE_CAPABILITIES[profile_name] - profile, _ = AccessProfile.objects.get_or_create( - organization=employee.organization, - name=profile_name, - defaults={ - "description": "System access profile", - "is_system": True, - "is_active": True, - }, - ) - for code in capabilities: - AccessProfileCapability.objects.get_or_create( - access_profile=profile, capability_code=code - ) - assignment = EmployeeAccessAssignment.objects.filter( - employee=employee, - access_profile=profile, - scope_type=ScopeType.DEPARTMENT, - department=department, - revoked_at__isnull=True, - ).first() - if assignment is not None: - return assignment - return EmployeeAccessAssignment.objects.create( - employee=employee, - access_profile=profile, - scope_type=ScopeType.DEPARTMENT, - department=department, - assigned_by=assigned_by, - ) diff --git a/apps/backend/hub_platform/identity/access_models.py b/apps/backend/hub_platform/identity/access_models.py deleted file mode 100644 index 251d38e..0000000 --- a/apps/backend/hub_platform/identity/access_models.py +++ /dev/null @@ -1,153 +0,0 @@ -from __future__ import annotations - -from django.core.exceptions import ValidationError -from django.db import models -from django.db.models import Q -from django.db.models.functions import Lower - -from hub_platform.identity.capabilities import CAPABILITY_REGISTRY, ScopeType, capability_spec -from hub_platform.identity.models import Department, Organization, OrganizationMembership -from hub_platform.tenancy.models import TenantRelationModel - - -class AccessProfile(models.Model): - organization = models.ForeignKey( - Organization, on_delete=models.PROTECT, related_name="access_profiles" - ) - name = models.CharField(max_length=120) - description = models.TextField(blank=True) - is_system = models.BooleanField(default=False) - is_active = models.BooleanField(default=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - Lower("name"), "organization", name="uniq_access_profile_org_name_ci" - ) - ] - - def clean(self) -> None: - self.name = self.name.strip() - if not self.name: - raise ValidationError({"name": "Profile name is required"}) - - def save(self, *args, **kwargs) -> None: - self.full_clean() - super().save(*args, **kwargs) - - def __str__(self) -> str: - return f"{self.organization.slug}/{self.name}" - - -class AccessProfileCapability(TenantRelationModel): - tenant_relation_fields = ("access_profile",) - access_profile = models.ForeignKey( - AccessProfile, on_delete=models.CASCADE, related_name="capability_links" - ) - capability_code = models.CharField(max_length=80) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["access_profile", "capability_code"], - name="uniq_access_profile_capability", - ), - models.CheckConstraint( - condition=Q( - capability_code__in=[ - code - for code, spec in CAPABILITY_REGISTRY.items() - if spec.assignable and not spec.protected - ] - ), - name="access_profile_capability_registry", - ), - ] - - def clean(self) -> None: - try: - spec = capability_spec(self.capability_code) - except ValueError as error: - raise ValidationError({"capability_code": str(error)}) from error - if not spec.assignable or spec.protected: - raise ValidationError({"capability_code": "Protected capability cannot be assigned"}) - - def save(self, *args, **kwargs) -> None: - self.validate_tenant_relations() - self.full_clean() - super().save(*args, **kwargs) - - -class EmployeeAccessAssignment(TenantRelationModel): - tenant_relation_fields = ( - "employee", - "access_profile", - "department", - "assigned_by", - ) - employee = models.ForeignKey( - OrganizationMembership, - on_delete=models.PROTECT, - related_name="access_assignments", - ) - access_profile = models.ForeignKey( - AccessProfile, on_delete=models.PROTECT, related_name="assignments" - ) - scope_type = models.CharField( - max_length=16, - choices=((ScopeType.ORGANIZATION, "Organization"), (ScopeType.DEPARTMENT, "Department")), - ) - department = models.ForeignKey( - Department, - on_delete=models.PROTECT, - related_name="access_assignments", - null=True, - blank=True, - ) - assigned_by = models.ForeignKey( - OrganizationMembership, - on_delete=models.PROTECT, - related_name="access_assignments_created", - ) - created_at = models.DateTimeField(auto_now_add=True) - revoked_at = models.DateTimeField(null=True, blank=True) - - class Meta: - constraints = [ - models.CheckConstraint( - condition=(Q(scope_type=ScopeType.ORGANIZATION, department__isnull=True)) - | (Q(scope_type=ScopeType.DEPARTMENT, department__isnull=False)), - name="access_assignment_scope_department", - ), - models.UniqueConstraint( - fields=["employee", "access_profile"], - condition=Q(scope_type=ScopeType.ORGANIZATION, revoked_at__isnull=True), - name="uniq_active_org_access_assignment", - ), - models.UniqueConstraint( - fields=["employee", "access_profile", "department"], - condition=Q(scope_type=ScopeType.DEPARTMENT, revoked_at__isnull=True), - name="uniq_active_dept_access_assignment", - ), - ] - - def clean(self) -> None: - organization_id = self.employee.organization_id - if self.access_profile.organization_id != organization_id: - raise ValidationError("Employee and access profile must belong to one organization") - if self.assigned_by.organization_id != organization_id: - raise ValidationError("Assigning employee must belong to the same organization") - if self.department_id and self.department.organization_id != organization_id: - raise ValidationError("Department must belong to the same organization") - if not self.access_profile.is_active and self.revoked_at is None: - raise ValidationError("Disabled access profile cannot be assigned") - expected_department = self.scope_type == ScopeType.DEPARTMENT - if expected_department != bool(self.department_id): - raise ValidationError("Department is required only for DEPARTMENT scope") - - def save(self, *args, **kwargs) -> None: - self.validate_tenant_relations() - self.full_clean() - super().save(*args, **kwargs) diff --git a/apps/backend/hub_platform/identity/access_payloads.py b/apps/backend/hub_platform/identity/access_payloads.py deleted file mode 100644 index debf709..0000000 --- a/apps/backend/hub_platform/identity/access_payloads.py +++ /dev/null @@ -1,61 +0,0 @@ -from hub_platform.identity.access_services import allowed_profile_scopes -from hub_platform.identity.capabilities import CAPABILITY_REGISTRY, capability_spec -from hub_platform.identity.models import AccessProfile, EmployeeAccessAssignment - - -def profile_payload(profile: AccessProfile) -> dict[str, object]: - capability_codes = sorted( - profile.capability_links.values_list("capability_code", flat=True) - ) - assigned_count = getattr(profile, "active_assignment_count", None) - if assigned_count is None: - assigned_count = profile.assignments.filter(revoked_at__isnull=True).count() - return { - "id": profile.id, - "name": profile.name, - "description": profile.description, - "isSystem": profile.is_system, - "isActive": profile.is_active, - "capabilities": capability_codes, - "allowedScopes": sorted(allowed_profile_scopes(capability_codes)), - "assignedCount": assigned_count, - } - - -def assignment_payload(assignment: EmployeeAccessAssignment) -> dict[str, object]: - return { - "id": assignment.id, - "profile": profile_payload(assignment.access_profile), - "scopeType": assignment.scope_type, - "departmentId": assignment.department_id, - "departmentCode": assignment.department.code if assignment.department_id else None, - "revokedAt": assignment.revoked_at.isoformat() if assignment.revoked_at else None, - } - - -def capability_codes(raw: object) -> tuple[list[str], str | None]: - if not isinstance(raw, list) or any(not isinstance(code, str) for code in raw): - return [], "capabilities must be a list of registry codes" - codes = list(dict.fromkeys(raw)) - try: - for code in codes: - spec = capability_spec(code) - if not spec.assignable or spec.protected: - return [], f"Capability cannot be assigned: {code}" - except ValueError as error: - return [], str(error) - return codes, None - - -def capability_registry_payload() -> list[dict[str, object]]: - return [ - { - "code": spec.code, - "name": spec.name, - "description": spec.description, - "allowedScopes": sorted(spec.allowed_scopes), - "assignable": spec.assignable, - "protected": spec.protected, - } - for spec in CAPABILITY_REGISTRY.values() - ] diff --git a/apps/backend/hub_platform/identity/access_services.py b/apps/backend/hub_platform/identity/access_services.py deleted file mode 100644 index d67935e..0000000 --- a/apps/backend/hub_platform/identity/access_services.py +++ /dev/null @@ -1,59 +0,0 @@ -from django.core.exceptions import ValidationError - -from hub_platform.identity.capabilities import CAPABILITY_REGISTRY, ScopeType -from hub_platform.identity.models import ( - AccessProfile, - Department, - DepartmentStatus, - EmployeeAccessAssignment, - EmployeeRole, - OrganizationMembership, -) - - -def allowed_profile_scopes(capability_codes: list[str]) -> set[str]: - allowed = {ScopeType.ORGANIZATION, ScopeType.DEPARTMENT} - for code in capability_codes: - allowed.intersection_update(CAPABILITY_REGISTRY[code].allowed_scopes) - return allowed - - -def create_access_assignment( - *, actor: OrganizationMembership, employee: OrganizationMembership, payload: dict -) -> EmployeeAccessAssignment: - if employee.role != EmployeeRole.EMPLOYEE: - raise ValidationError("Access assignments are only allowed for EMPLOYEE") - profile = AccessProfile.objects.filter( - id=payload.get("profileId"), - organization=actor.organization, - is_active=True, - ).first() - if profile is None: - raise ValidationError("Active access profile not found") - - scope_type = str(payload.get("scopeType", "")) - department = None - if scope_type == ScopeType.DEPARTMENT: - department = Department.objects.filter( - id=payload.get("departmentId"), - organization=actor.organization, - status=DepartmentStatus.ACTIVE, - ).first() - if department is None: - raise ValidationError("Active department not found") - elif scope_type != ScopeType.ORGANIZATION: - raise ValidationError("Invalid scope type") - - capability_codes = list( - profile.capability_links.values_list("capability_code", flat=True) - ) - if scope_type not in allowed_profile_scopes(capability_codes): - raise ValidationError("Access profile does not allow the requested scope") - - return EmployeeAccessAssignment.objects.create( - employee=employee, - access_profile=profile, - scope_type=scope_type, - department=department, - assigned_by=actor, - ) diff --git a/apps/backend/hub_platform/identity/access_urls.py b/apps/backend/hub_platform/identity/access_urls.py deleted file mode 100644 index f2ac46f..0000000 --- a/apps/backend/hub_platform/identity/access_urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from hub_platform.identity import access_views - -urlpatterns = [ - path("", access_views.AccessProfileListCreateView.as_view(), name="access-profile-list"), - path("capabilities/", access_views.CapabilityRegistryView.as_view(), name="capability-registry"), - path("/", access_views.AccessProfileDetailView.as_view(), name="access-profile-detail"), -] diff --git a/apps/backend/hub_platform/identity/access_views.py b/apps/backend/hub_platform/identity/access_views.py deleted file mode 100644 index 46323e7..0000000 --- a/apps/backend/hub_platform/identity/access_views.py +++ /dev/null @@ -1,253 +0,0 @@ -from __future__ import annotations - -from django.core.exceptions import ValidationError -from django.db import IntegrityError, transaction -from django.db.models import Count, Q -from django.utils import timezone -from rest_framework.permissions import IsAuthenticated -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from hub_platform.identity.audit import record_audit_event -from hub_platform.identity.access_payloads import ( - assignment_payload, - capability_codes, - capability_registry_payload, - profile_payload, -) -from hub_platform.identity.access_services import allowed_profile_scopes, create_access_assignment -from hub_platform.identity.governance import EmployeeAction, can_manage_employee -from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - EmployeeAccessAssignment, - OrganizationMembership, -) -from hub_platform.identity.policy import can_administer_access - - -def _manager_required(request: Request) -> Response | None: - if can_administer_access(request.tenant_context.membership): - return None - return Response({"detail": "Employee access management is not allowed"}, status=403) - - -class CapabilityRegistryView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request: Request) -> Response: - if (denied := _manager_required(request)) is not None: - return denied - return Response({"items": capability_registry_payload()}) - - -class AccessProfileListCreateView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request: Request) -> Response: - if (denied := _manager_required(request)) is not None: - return denied - profiles = AccessProfile.objects.filter( - organization=request.tenant_context.organization - ).prefetch_related("capability_links").annotate( - active_assignment_count=Count( - "assignments", - filter=Q(assignments__revoked_at__isnull=True), - distinct=True, - ) - ) - return Response({"items": [profile_payload(profile) for profile in profiles.order_by("name")]}) - - @transaction.atomic - def post(self, request: Request) -> Response: - if (denied := _manager_required(request)) is not None: - return denied - codes, error = capability_codes(request.data.get("capabilities", [])) - if error: - return Response({"detail": error}, status=400) - actor = request.tenant_context.membership - name = str(request.data.get("name", "")).strip() - if not name: - return Response({"detail": "Access profile name is required"}, status=400) - try: - profile = AccessProfile.objects.create( - organization=actor.organization, - name=name, - description=str(request.data.get("description", "")).strip(), - ) - AccessProfileCapability.objects.bulk_create( - [ - AccessProfileCapability( - organization=profile.organization, - access_profile=profile, - capability_code=code, - ) - for code in codes - ] - ) - except (ValidationError, IntegrityError) as exc: - return Response({"detail": str(exc)}, status=400) - record_audit_event( - action="access_profile.created", - actor=request.user, - organization=actor.organization, - object_type="AccessProfile", - object_id=str(profile.id), - payload={"capabilities": codes}, - request=request, - ) - return Response({"profile": profile_payload(profile)}, status=201) - - -class AccessProfileDetailView(APIView): - permission_classes = [IsAuthenticated] - - def _profile(self, request: Request, profile_id: int) -> AccessProfile | None: - return ( - AccessProfile.objects.filter( - id=profile_id, organization=request.tenant_context.organization - ) - .prefetch_related("capability_links") - .first() - ) - - @transaction.atomic - def patch(self, request: Request, profile_id: int) -> Response: - if (denied := _manager_required(request)) is not None: - return denied - profile = self._profile(request, profile_id) - if profile is None: - return Response({"detail": "Access profile not found"}, status=404) - if profile.is_system: - return Response({"detail": "System access profile is read-only"}, status=409) - codes, error = capability_codes( - request.data.get( - "capabilities", - list(profile.capability_links.values_list("capability_code", flat=True)), - ) - ) - if error: - return Response({"detail": error}, status=400) - requested_scopes = set( - profile.assignments.filter(revoked_at__isnull=True).values_list( - "scope_type", flat=True - ) - ) - if not requested_scopes.issubset(allowed_profile_scopes(codes)): - return Response( - {"detail": "Profile capabilities conflict with active assignment scopes"}, - status=409, - ) - profile.name = str(request.data.get("name", profile.name)).strip() - if not profile.name: - return Response({"detail": "Access profile name is required"}, status=400) - profile.description = str(request.data.get("description", profile.description)).strip() - profile.is_active = bool(request.data.get("isActive", profile.is_active)) - try: - profile.save() - profile.capability_links.all().delete() - AccessProfileCapability.objects.bulk_create( - [ - AccessProfileCapability( - organization=profile.organization, - access_profile=profile, - capability_code=code, - ) - for code in codes - ] - ) - except (ValidationError, IntegrityError) as exc: - return Response({"detail": str(exc)}, status=400) - record_audit_event( - action="access_profile.updated" if profile.is_active else "access_profile.disabled", - actor=request.user, - organization=profile.organization, - object_type="AccessProfile", - object_id=str(profile.id), - payload={"capabilities": codes}, - request=request, - ) - return Response({"profile": profile_payload(profile)}) - - def delete(self, request: Request, profile_id: int) -> Response: - if (denied := _manager_required(request)) is not None: - return denied - profile = self._profile(request, profile_id) - if profile is None: - return Response({"detail": "Access profile not found"}, status=404) - if profile.is_system or profile.assignments.exists(): - return Response({"detail": "Profile must be disabled to preserve access history"}, status=409) - record_audit_event( - action="access_profile.deleted", - actor=request.user, - organization=profile.organization, - object_type="AccessProfile", - object_id=str(profile.id), - request=request, - ) - profile.delete() - return Response(status=204) - - -class EmployeeAccessAssignmentView(APIView): - permission_classes = [IsAuthenticated] - - @transaction.atomic - def post(self, request: Request, user_id: int) -> Response: - actor = request.tenant_context.membership - target = OrganizationMembership.objects.filter( - user_id=user_id, organization=actor.organization - ).first() - if target is None: - return Response({"detail": "Employee not found"}, status=404) - if not can_manage_employee(actor, target, EmployeeAction.CHANGE_ACCESS): - return Response({"detail": "Access assignment is not allowed"}, status=403) - try: - assignment = create_access_assignment( - actor=actor, employee=target, payload=request.data - ) - except ValidationError as exc: - return Response({"detail": str(exc)}, status=400) - except IntegrityError as exc: - return Response({"detail": str(exc)}, status=409) - record_audit_event( - action="access_assignment.created", - actor=request.user, - organization=actor.organization, - object_type="EmployeeAccessAssignment", - object_id=str(assignment.id), - payload={"employeeId": target.user_id, "scopeType": assignment.scope_type}, - request=request, - ) - return Response({"assignment": assignment_payload(assignment)}, status=201) - - -class EmployeeAccessAssignmentRevokeView(APIView): - permission_classes = [IsAuthenticated] - - def delete(self, request: Request, user_id: int, assignment_id: int) -> Response: - actor = request.tenant_context.membership - assignment = EmployeeAccessAssignment.objects.select_related( - "employee", "access_profile", "department" - ).filter( - id=assignment_id, - employee__user_id=user_id, - employee__organization=actor.organization, - revoked_at__isnull=True, - ).first() - if assignment is None: - return Response({"detail": "Access assignment not found"}, status=404) - if not can_manage_employee(actor, assignment.employee, EmployeeAction.CHANGE_ACCESS): - return Response({"detail": "Access assignment is not allowed"}, status=403) - assignment.revoked_at = timezone.now() - assignment.save(update_fields=["revoked_at"]) - record_audit_event( - action="access_assignment.revoked", - actor=request.user, - organization=actor.organization, - object_type="EmployeeAccessAssignment", - object_id=str(assignment.id), - request=request, - ) - return Response({"assignment": assignment_payload(assignment)}) diff --git a/apps/backend/hub_platform/identity/admin.py b/apps/backend/hub_platform/identity/admin.py index 8757d85..39b6e5c 100644 --- a/apps/backend/hub_platform/identity/admin.py +++ b/apps/backend/hub_platform/identity/admin.py @@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin from hub_platform.identity.models import ( AuditEvent, - Department, + EmployeeGroup, HumanUser, Organization, OrganizationInvitation, @@ -39,11 +39,11 @@ class OrganizationAdmin(admin.ModelAdmin): search_fields = ["slug", "name"] -@admin.register(Department) -class DepartmentAdmin(admin.ModelAdmin): - list_display = ["code", "name", "organization", "status"] - list_filter = ["organization", "status"] - search_fields = ["code", "name"] +@admin.register(EmployeeGroup) +class EmployeeGroupAdmin(admin.ModelAdmin): + list_display = ["name", "organization", "created_at"] + list_filter = ["organization"] + search_fields = ["name"] @admin.register(OrganizationMembership) @@ -53,7 +53,6 @@ class OrganizationMembershipAdmin(admin.ModelAdmin): "organization", "role", "position_title", - "primary_department", "must_change_password", "totp_required", "blocked_at", @@ -61,7 +60,6 @@ class OrganizationMembershipAdmin(admin.ModelAdmin): list_filter = [ "organization", "role", - "primary_department", "user__must_change_password", "totp_required", ] diff --git a/apps/backend/hub_platform/identity/auth/common.py b/apps/backend/hub_platform/identity/auth/common.py index 6ea51b9..5c7ebc7 100644 --- a/apps/backend/hub_platform/identity/auth/common.py +++ b/apps/backend/hub_platform/identity/auth/common.py @@ -20,9 +20,7 @@ def _user_payload(user: HumanUser) -> dict[str, object]: continue with tenant_atomic(organization.id): membership = ( - OrganizationMembership.objects.select_related( - "organization", "primary_department" - ) + OrganizationMembership.objects.select_related("organization") .filter( id=route.resource_id, user=user, @@ -46,9 +44,6 @@ def _user_payload(user: HumanUser) -> dict[str, object]: ), "role": membership.role, "positionTitle": membership.position_title, - "department": ( - membership.primary_department.code if membership.primary_department else None - ), "totpRequired": membership.totp_required, } membership_payload.update(get_effective_access(membership)) diff --git a/apps/backend/hub_platform/identity/bootstrap.py b/apps/backend/hub_platform/identity/bootstrap.py index 6097aec..ed8580d 100644 --- a/apps/backend/hub_platform/identity/bootstrap.py +++ b/apps/backend/hub_platform/identity/bootstrap.py @@ -3,22 +3,21 @@ from dataclasses import dataclass from django.db import transaction from hub_platform.identity.audit import record_audit_event -from hub_platform.identity.access_defaults import ensure_system_assignment +from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember from hub_platform.identity.models import ( - Department, EmployeeRole, HumanUser, Organization, OrganizationMembership, ) -from hub_platform.products.models import Product, ProductDepartment +from hub_platform.products.models import Product @dataclass(frozen=True) class BootstrapResult: organization: Organization - sales_department: Department - support_department: Department + operators_group: EmployeeGroup + support_group: EmployeeGroup owner: HumanUser created_owner: bool @@ -36,23 +35,17 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") -> from hub_platform.ai.knowledge_categories import ensure_uncategorized_category ensure_uncategorized_category(organization) - sales_department, _ = Department.objects.get_or_create( - organization=organization, - code="sales", - defaults={"name": "Продажи"}, + # Группы сотрудников (ADR-HUB-0043): не обязательны для запуска, но дают + # локальному контуру и тестам готовое разделение потоков. + operators_group, _ = EmployeeGroup.objects.get_or_create( + organization=organization, name="Операторы" ) - # Отдел поддержки (ADR-HUB-0022, SPEC-HUB-0010 §4.1): authenticated in-product - # чат существующих клиентов продуктов. Сосуществует с sales, identity разделены. - support_department, _ = Department.objects.get_or_create( - organization=organization, - code="support", - defaults={"name": "Поддержка"}, + support_group, _ = EmployeeGroup.objects.get_or_create( + organization=organization, name="Поддержка" ) for code, name in (("firepage", "FirePage"), ("foxray", "Foxray")): - product, _ = Product.objects.get_or_create(organization=organization, code=code, defaults={"name": name}) - ProductDepartment.objects.get_or_create(product=product, department=sales_department) - # Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов, - # а не bootstrap: SPEC-HUB-0027. + Product.objects.get_or_create(organization=organization, code=code, defaults={"name": name}) + # Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов. owner, created_owner = HumanUser.objects.get_or_create( email=HumanUser.objects.normalize_email(email), @@ -70,14 +63,12 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") -> owner.is_superuser = True owner.save(update_fields=["is_staff", "is_superuser"]) - owner_profile, _ = OrganizationMembership.objects.get_or_create( + OrganizationMembership.objects.get_or_create( user=owner, organization=organization, defaults={ "role": EmployeeRole.OWNER, "position_title": "Владелец", - # OWNER всегда на уровне компании (ADR-HUB-0027): без основного отдела. - "primary_department": None, "totp_required": False, }, ) @@ -104,19 +95,17 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") -> organization=organization, defaults={ "role": EmployeeRole.EMPLOYEE, - "position_title": "Оператор отдела продаж", + "position_title": "Оператор", "phone": "+7 916 245 14 02", - "primary_department": sales_department, }, ) if not operator_profile.phone: operator_profile.phone = "+7 916 245 14 02" operator_profile.save(update_fields=["phone"]) - ensure_system_assignment( + EmployeeGroupMember.objects.get_or_create( + organization=organization, + group=operators_group, employee=operator_profile, - assigned_by=owner_profile, - department=sales_department, - profile_name="Sales operator", ) record_audit_event( @@ -130,8 +119,8 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") -> return BootstrapResult( organization=organization, - sales_department=sales_department, - support_department=support_department, + operators_group=operators_group, + support_group=support_group, owner=owner, created_owner=created_owner, ) diff --git a/apps/backend/hub_platform/identity/capabilities.py b/apps/backend/hub_platform/identity/capabilities.py index 0bb3945..0521744 100644 --- a/apps/backend/hub_platform/identity/capabilities.py +++ b/apps/backend/hub_platform/identity/capabilities.py @@ -1,100 +1,54 @@ from __future__ import annotations -from dataclasses import dataclass +# Возможности как словарь операций backend'а (ADR-HUB-0041, SPEC-HUB-0031 §3). +# Права выводятся ТОЛЬКО из роли: OWNER и ADMIN идентичны и получают всё; +# EMPLOYEE получает фиксированный набор для работы в чате. Профили доступа, +# scope-модель и отделы упразднены (ADR-HUB-0043). - -class ScopeType: - ORGANIZATION = "ORGANIZATION" - DEPARTMENT = "DEPARTMENT" - - -@dataclass(frozen=True, slots=True) -class CapabilitySpec: - code: str - name: str - description: str - allowed_scopes: frozenset[str] - assignable: bool = True - protected: bool = False - - -_ALL_SCOPES = frozenset({ScopeType.ORGANIZATION, ScopeType.DEPARTMENT}) -_ORGANIZATION_ONLY = frozenset({ScopeType.ORGANIZATION}) - - -def _capability( - code: str, - name: str, - *, - scopes: frozenset[str] = _ALL_SCOPES, - assignable: bool = True, - protected: bool = False, -) -> CapabilitySpec: - return CapabilitySpec( - code=code, - name=name, - description=name, - allowed_scopes=scopes, - assignable=assignable, - protected=protected, - ) - - -# SPEC-HUB-0017 §5.2. The registry is application code, never database data. -CAPABILITY_REGISTRY = { - item.code: item - for item in ( - _capability("company.view", "Просмотр компании", scopes=_ORGANIZATION_ONLY), - _capability("company.manage", "Управление компанией", scopes=_ORGANIZATION_ONLY), - _capability("departments.view", "Просмотр отделов"), - _capability("departments.manage", "Управление отделами", scopes=_ORGANIZATION_ONLY), - _capability("employees.view", "Просмотр сотрудников"), - _capability("employees.manage", "Управление сотрудниками", scopes=_ORGANIZATION_ONLY), - _capability( - "employees.manage_privileged", - "Управление привилегированными сотрудниками", - scopes=_ORGANIZATION_ONLY, - assignable=False, - protected=True, - ), - _capability( - "ownership.transfer", - "Передача владения", - scopes=_ORGANIZATION_ONLY, - assignable=False, - protected=True, - ), - _capability("products.view", "Просмотр продуктов"), - _capability("products.manage", "Управление продуктами"), - _capability("channels.view", "Просмотр каналов"), - _capability("channels.manage", "Управление каналами"), - _capability("ai.view", "Просмотр агентов"), - _capability("ai.manage", "Настройка агентов"), - _capability("ai.publish", "Публикация агентов", scopes=_ORGANIZATION_ONLY), - _capability("integrations.view", "Просмотр интеграций", scopes=_ORGANIZATION_ONLY), - _capability("integrations.manage", "Управление интеграциями", scopes=_ORGANIZATION_ONLY), - _capability("secrets.manage", "Управление секретами", scopes=_ORGANIZATION_ONLY), - _capability("settings.view", "Просмотр настроек", scopes=_ORGANIZATION_ONLY), - _capability("settings.manage", "Управление настройками", scopes=_ORGANIZATION_ONLY), - _capability("audit.view", "Просмотр аудита", scopes=_ORGANIZATION_ONLY), - _capability("conversations.view", "Просмотр диалогов"), - _capability("conversations.operate", "Работа с диалогами"), - _capability("conversations.call", "Звонки"), - _capability("customers.view", "Просмотр клиентов"), - _capability("customers.manage", "Управление клиентами"), - _capability("support.view", "Просмотр поддержки"), - _capability("support.operate", "Работа с поддержкой"), - _capability("notifications.manage", "Управление уведомлениями", scopes=_ORGANIZATION_ONLY), - ) -} - -PROTECTED_CAPABILITIES = frozenset( - code for code, spec in CAPABILITY_REGISTRY.items() if spec.protected +ALL_CAPABILITIES: frozenset[str] = frozenset( + { + "company.view", + "company.manage", + "employees.view", + "employees.manage", + "groups.manage", + "products.view", + "products.manage", + "channels.view", + "channels.manage", + "ai.view", + "ai.manage", + "ai.publish", + "integrations.view", + "integrations.manage", + "secrets.manage", + "settings.view", + "settings.manage", + "audit.view", + "conversations.view", + "conversations.operate", + "conversations.call", + "customers.view", + "customers.manage", + "support.view", + "support.operate", + "notifications.manage", + "employees.manage_privileged", + "ownership.transfer", + } ) +# Сотрудник работает в одном окне — чате: диалоги, звонки, карточка контакта. +EMPLOYEE_CAPABILITIES: frozenset[str] = frozenset( + { + "conversations.view", + "conversations.operate", + "conversations.call", + "customers.view", + "support.view", + "support.operate", + } +) -def capability_spec(code: str) -> CapabilitySpec: - try: - return CAPABILITY_REGISTRY[code] - except KeyError as error: - raise ValueError(f"Unknown capability: {code}") from error +# Операции, доступные только владельцу (SPEC-HUB-0031 §3: передача владения). +OWNER_ONLY_CAPABILITIES: frozenset[str] = frozenset({"ownership.transfer"}) diff --git a/apps/backend/hub_platform/identity/company_urls.py b/apps/backend/hub_platform/identity/company_urls.py index 48d2f3f..25db91c 100644 --- a/apps/backend/hub_platform/identity/company_urls.py +++ b/apps/backend/hub_platform/identity/company_urls.py @@ -1,9 +1,10 @@ from django.urls import path -from hub_platform.identity import administration_views, company_views +from hub_platform.identity import administration_views, group_views urlpatterns = [ - path("departments/", company_views.DepartmentListView.as_view(), name="department-list"), + path("groups/", group_views.GroupListView.as_view(), name="group-list"), + path("groups//", group_views.GroupDetailView.as_view(), name="group-detail"), path( "administration/", administration_views.OrganizationSettingsView.as_view(), diff --git a/apps/backend/hub_platform/identity/company_views.py b/apps/backend/hub_platform/identity/company_views.py deleted file mode 100644 index d7a8072..0000000 --- a/apps/backend/hub_platform/identity/company_views.py +++ /dev/null @@ -1,56 +0,0 @@ -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from hub_platform.ai.models import AIAgent -from hub_platform.api.permissions import HasCapability -from hub_platform.identity.models import Department, OrganizationMembership -from hub_platform.identity.policy import ResourceScope, accessible_department_ids, authorize - - -def _department_payload(department: Department) -> dict[str, object]: - employees = list( - OrganizationMembership.objects.filter(organization=department.organization).select_related("user") - ) - department_members = [ - employee for employee in employees if employee.primary_department_id == department.id - ] - operators = [ - employee - for employee in employees - if authorize( - employee, - "conversations.operate", - ResourceScope(department.organization_id, department.id), - ) - ] - products = [link.product for link in department.product_links.select_related("product").order_by("product__name")] - # AI-агенты отдела: AIAgent живёт на канале обработки (ADR-HUB-0019), - # канал принадлежит отделу. Считаем активных агентов каналов этого отдела. - agent_count = AIAgent.objects.filter(channel__department=department).count() - return { - "id": department.id, - "code": department.code, - "name": department.name, - "status": department.status, - "memberCount": len(department_members), - "operatorCount": len(operators), - "activeOperatorCount": len( - [employee for employee in operators if employee.user.is_active and not employee.is_blocked] - ), - "agentCount": agent_count, - "products": [{"code": product.code, "name": product.name} for product in products], - } - - -class DepartmentListView(APIView): - permission_classes = [HasCapability] - required_capability = "departments.view" - - def get(self, request: Request) -> Response: - profile = request.tenant_context.membership - departments = Department.objects.filter(organization=profile.organization).order_by("name") - department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) - if department_ids is not None: - departments = departments.filter(id__in=department_ids) - return Response({"items": [_department_payload(department) for department in departments]}) diff --git a/apps/backend/hub_platform/identity/demo_seed/data/catalog.json b/apps/backend/hub_platform/identity/demo_seed/data/catalog.json index 2082db0..3e77b35 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/catalog.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/catalog.json @@ -4,20 +4,12 @@ { "code": "vektor", "name": "Вектор", - "siteUrl": "https://vektor.severnayaverf.ru", - "departments": [ - "sales", - "support" - ] + "siteUrl": "https://vektor.severnayaverf.ru" }, { "code": "reper", "name": "Репер", - "siteUrl": "https://reper.severnayaverf.ru", - "departments": [ - "sales", - "support" - ] + "siteUrl": "https://reper.severnayaverf.ru" } ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json b/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json index ba301e7..99aad17 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json @@ -1,17 +1,84 @@ { "schemaVersion": 1, "integrations": [ - { "key": "llm-provider", "provider": "OPENROUTER", "name": "OpenRouter (CustoAI)", "kind": "LLM_PROVIDER", "channel": null, "status": "OK" }, - { "key": "site-max", "provider": "MAX", "name": "Сайт — MAX", "channel": "site-main", "status": "OK" }, - { "key": "site-tg", "provider": "TELEGRAM", "name": "Сайт — Telegram", "channel": "site-main", "status": "OK" }, - { "key": "site-web", "provider": "WEB", "name": "Сайт — веб-чат", "channel": "site-main", "status": "OK" }, - { "key": "vektor-sales-tg", "provider": "TELEGRAM", "name": "Вектор продажи — Telegram", "channel": "vektor-sales", "status": "OK" }, - { "key": "vektor-sales-web", "provider": "WEB", "name": "Вектор продажи — веб-чат", "channel": "vektor-sales", "status": "OK" }, - { "key": "reper-sales-tg", "provider": "TELEGRAM", "name": "Репер продажи — Telegram", "channel": "reper-sales", "status": "OK" }, - { "key": "reper-sales-max", "provider": "MAX", "name": "Репер продажи — MAX", "channel": "reper-sales", "status": "UNCHECKED" }, - { "key": "vektor-support-web", "provider": "WEB", "name": "Вектор поддержка — веб-чат", "channel": "vektor-support", "status": "OK" }, - { "key": "reper-support-web", "provider": "WEB", "name": "Репер поддержка — веб-чат", "channel": "reper-support", "status": "OK" }, - { "key": "partners-tg", "provider": "TELEGRAM", "name": "Партнёрская линия — Telegram", "channel": "partners", "status": "OK" }, + { + "key": "llm-provider", + "provider": "OPENROUTER", + "name": "OpenRouter (CustoAI)", + "kind": "LLM_PROVIDER", + "channel": null, + "status": "OK" + }, + { + "key": "site-max", + "provider": "MAX", + "name": "Сайт — MAX", + "channel": "site-main", + "status": "OK" + }, + { + "key": "site-tg", + "provider": "TELEGRAM", + "name": "Сайт — Telegram", + "channel": "site-main", + "status": "OK" + }, + { + "key": "site-web", + "provider": "WEB", + "name": "Сайт — веб-чат", + "channel": "site-main", + "status": "OK" + }, + { + "key": "vektor-sales-tg", + "provider": "TELEGRAM", + "name": "Вектор продажи — Telegram", + "channel": "vektor-sales", + "status": "OK" + }, + { + "key": "vektor-sales-web", + "provider": "WEB", + "name": "Вектор продажи — веб-чат", + "channel": "vektor-sales", + "status": "OK" + }, + { + "key": "reper-sales-tg", + "provider": "TELEGRAM", + "name": "Репер продажи — Telegram", + "channel": "reper-sales", + "status": "OK" + }, + { + "key": "reper-sales-max", + "provider": "MAX", + "name": "Репер продажи — MAX", + "channel": "reper-sales", + "status": "UNCHECKED" + }, + { + "key": "vektor-support-web", + "provider": "WEB", + "name": "Вектор поддержка — веб-чат", + "channel": "vektor-support", + "status": "OK" + }, + { + "key": "reper-support-web", + "provider": "WEB", + "name": "Репер поддержка — веб-чат", + "channel": "reper-support", + "status": "OK" + }, + { + "key": "partners-tg", + "provider": "TELEGRAM", + "name": "Партнёрская линия — Telegram", + "channel": "partners", + "status": "OK" + }, { "key": "support-email", "provider": "EMAIL", @@ -33,7 +100,6 @@ { "code": "site-main", "name": "Северная Верфь — главный сайт", - "department": "sales", "product": null, "providerIntegration": "llm-provider", "policy": { @@ -42,12 +108,12 @@ "allow_self_reported_contact": true, "allow_sales_attribution": false, "allow_checkout_actions": false - } + }, + "group": "sales" }, { "code": "vektor-sales", "name": "Вектор — продажи", - "department": "sales", "product": "vektor", "providerIntegration": "llm-provider", "policy": { @@ -56,12 +122,12 @@ "allow_self_reported_contact": true, "allow_sales_attribution": true, "allow_checkout_actions": true - } + }, + "group": "sales" }, { "code": "reper-sales", "name": "Репер — продажи", - "department": "sales", "product": "reper", "providerIntegration": "llm-provider", "policy": { @@ -70,12 +136,12 @@ "allow_self_reported_contact": true, "allow_sales_attribution": true, "allow_checkout_actions": true - } + }, + "group": "sales" }, { "code": "vektor-support", "name": "Вектор — поддержка", - "department": "support", "product": "vektor", "policy": { "requires_authenticated_product_identity": true, @@ -83,12 +149,12 @@ "allow_self_reported_contact": false, "allow_sales_attribution": false, "allow_checkout_actions": false - } + }, + "group": "support" }, { "code": "reper-support", "name": "Репер — поддержка", - "department": "support", "product": "reper", "policy": { "requires_authenticated_product_identity": true, @@ -96,12 +162,12 @@ "allow_self_reported_contact": false, "allow_sales_attribution": false, "allow_checkout_actions": false - } + }, + "group": "support" }, { "code": "partners", "name": "Партнёрская линия", - "department": null, "product": null, "isActive": false, "policy": { @@ -110,12 +176,12 @@ "allow_self_reported_contact": true, "allow_sales_attribution": false, "allow_checkout_actions": false - } + }, + "group": null }, { "code": "reper-help-widget", "name": "Репер — виджет Help-центра", - "department": "support", "product": "reper", "policy": { "requires_authenticated_product_identity": false, @@ -123,7 +189,8 @@ "allow_self_reported_contact": true, "allow_sales_attribution": false, "allow_checkout_actions": false - } + }, + "group": "support" } ], "agents": [ @@ -135,7 +202,10 @@ "persona": "Виртуальный ассистент компании «Северная Верфь» — производителя промышленной телеметрии. Помогает посетителям сайта сориентироваться в продуктах Вектор и Репер.", "tone": "Дружелюбный, деловой, без излишней фамильярности. Обращается на «вы».", "instructions": "Если вопрос касается конкретного продукта или условий покупки — переключай диалог на менеджера продаж. Не обещай скидки и сроки, которых нет в базе знаний.", - "knowledge": ["vektor-overview", "reper-overview"] + "knowledge": [ + "vektor-overview", + "reper-overview" + ] }, { "channel": "vektor-sales", @@ -145,7 +215,10 @@ "persona": "Отвечает за продажи коробочной лицензии Вектор производственным предприятиям.", "tone": "Деловой, экспертный, по сути.", "instructions": "Уточняй масштаб производства и число участков — от этого зависит конфигурация. Предлагай продление поддержки как сопутствующую услугу.", - "knowledge": ["vektor-overview", "vektor-deploy"] + "knowledge": [ + "vektor-overview", + "vektor-deploy" + ] }, { "channel": "reper-sales", @@ -155,7 +228,10 @@ "persona": "Отвечает за продажи облачной подписки на телематику Репер.", "tone": "Деловой, ориентируется на выгоду клиента.", "instructions": "Уточняй размер парка техники и типы транспорта. Для парков от 50 единиц предлагай годовую оплату — она выгоднее помесячной.", - "knowledge": ["reper-overview", "reper-telematics"] + "knowledge": [ + "reper-overview", + "reper-telematics" + ] } ], "knowledge": [ @@ -172,7 +248,9 @@ "description": "Требования к серверу и шаги развёртывания коробочной лицензии. Приложен файл с детальным руководством.", "content": "Подробное руководство по развёртыванию коробочной лицензии Вектор приведено во вложении. Требуется сервер с ОС Linux, 8 ГБ ОЗУ, доступ к контроллерам по сети.", "visibility": "DEPARTMENTS", - "attachments": ["vektor-deploy-guide.md"] + "attachments": [ + "vektor-deploy-guide.md" + ] }, { "key": "reper-overview", @@ -187,7 +265,9 @@ "description": "Поддерживаемые протоколы и бортовые контроллеры. Приложен файл со спецификацией.", "content": "Поддерживаемые протоколы обмена с бортовыми контроллерами и список совместимого оборудования приведены во вложении.", "visibility": "DEPARTMENTS", - "attachments": ["reper-telematics-spec.md"] + "attachments": [ + "reper-telematics-spec.md" + ] } ], "llmInvocations": [ diff --git a/apps/backend/hub_platform/identity/demo_seed/data/operations.json b/apps/backend/hub_platform/identity/demo_seed/data/operations.json index 563b73e..b0d1526 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/operations.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/operations.json @@ -10,7 +10,12 @@ "minutesAgo": 30, "durationSeconds": 240, "deliveryConnection": "reper-support-web", - "metrics": { "connectionType": "DIRECT", "localCandidateType": "host", "remoteCandidateType": "srflx", "roundTripMs": 42 } + "metrics": { + "connectionType": "DIRECT", + "localCandidateType": "host", + "remoteCandidateType": "srflx", + "roundTripMs": 42 + } }, { "conversation": "vektor-queue", @@ -29,7 +34,6 @@ "targetRoute": "conversations", "targetId": "vektor-queue", "audience": "OPERATORS", - "department": "sales", "sourceId": "vektor-queue", "readBy": "sales_lead" }, @@ -41,7 +45,6 @@ "targetRoute": "conversations", "targetId": "vektor-lead-active", "audience": "OPERATORS", - "department": "sales", "sourceId": "vektor-lead-active" }, { @@ -57,10 +60,30 @@ "daysElapsed": 15, "daysRemaining": 16, "counters": [ - { "quota": "ai_agent_slots", "unit": "slots", "used": 3, "reserved": 0 }, - { "quota": "new_dialogs_per_period", "unit": "dialogs", "used": 47, "reserved": 0 }, - { "quota": "managed_ai_credits", "unit": "credits", "used": 1840, "reserved": 0 }, - { "quota": "storage_bytes", "unit": "bytes", "used": 5242880, "reserved": 0 } + { + "quota": "ai_agent_slots", + "unit": "slots", + "used": 3, + "reserved": 0 + }, + { + "quota": "new_dialogs_per_period", + "unit": "dialogs", + "used": 47, + "reserved": 0 + }, + { + "quota": "managed_ai_credits", + "unit": "credits", + "used": 1840, + "reserved": 0 + }, + { + "quota": "storage_bytes", + "unit": "bytes", + "used": 5242880, + "reserved": 0 + } ] } } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/organization.json b/apps/backend/hub_platform/identity/demo_seed/data/organization.json index 17e9919..269ea15 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/organization.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/organization.json @@ -37,8 +37,7 @@ "role": "EMPLOYEE", "positionTitle": "Старший менеджер по продажам", "phone": "+7 921 330 11 45", - "department": "sales", - "systemProfile": "Sales operator" + "group": "sales" }, { "key": "sales_junior", @@ -48,8 +47,7 @@ "role": "EMPLOYEE", "positionTitle": "Менеджер по продажам", "phone": "+7 921 778 02 19", - "department": "sales", - "systemProfile": "Sales operator" + "group": "sales" }, { "key": "support_lead", @@ -59,8 +57,7 @@ "role": "EMPLOYEE", "positionTitle": "Старший инженер поддержки", "phone": "+7 812 240 19 50", - "department": "support", - "systemProfile": "Support operator" + "group": "support" }, { "key": "support_engineer", @@ -70,8 +67,17 @@ "role": "EMPLOYEE", "positionTitle": "Инженер технической поддержки", "phone": "+7 812 240 19 51", - "department": "support", - "systemProfile": "Support operator" + "group": "support" + } + ], + "groups": [ + { + "key": "sales", + "name": "Операторы" + }, + { + "key": "support", + "name": "Поддержка" } ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/catalog.py b/apps/backend/hub_platform/identity/demo_seed/loaders/catalog.py index 8b5d97a..0ac5f38 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/catalog.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/catalog.py @@ -1,10 +1,10 @@ -"""Каталог: продукты и связи отделов.""" +"""Каталог: продукты (скрытая техническая привязка, ADR-HUB-0041).""" from __future__ import annotations from hub_platform.identity.demo_seed import manifest from hub_platform.identity.demo_seed.refs import DemoRefs -from hub_platform.products.models import Product, ProductDepartment, ProductStatus +from hub_platform.products.models import Product, ProductStatus from hub_platform.tenancy.context import TenantContext @@ -23,7 +23,3 @@ def load(context: TenantContext, refs: DemoRefs) -> None: }, ) refs.products[item["code"]] = product - for department_code in item.get("departments", []): - department = refs.departments.get(department_code) - if department is not None: - ProductDepartment.objects.get_or_create(product=product, department=department) diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py b/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py index 22c5103..d14849d 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py @@ -4,7 +4,6 @@ from __future__ import annotations from django.core.files.base import ContentFile -from hub_platform.ai.knowledge_types import KnowledgeVisibility from hub_platform.ai.models import ( AIAgent, AIAgentStatus, @@ -59,7 +58,7 @@ def _ensure_channel(refs: DemoRefs, item: dict) -> None: code=item["code"], defaults={ "name": item["name"], - "department": refs.departments.get(item.get("department")), + "group": refs.groups.get(item.get("group")), "product": refs.products.get(item.get("product")), "provider_integration": refs.integrations.get(item.get("providerIntegration")), "is_active": item.get("isActive", True), @@ -106,7 +105,6 @@ def _ensure_knowledge(context: TenantContext, refs: DemoRefs, items: list[dict]) "category": category, "description": item.get("description", ""), "content": item.get("content", ""), - "visibility": item.get("visibility", KnowledgeVisibility.ORGANIZATION), }, ) refs.knowledge[item["key"]] = knowledge diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py b/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py index 4f50a61..4da9152 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py @@ -1,4 +1,4 @@ -"""Базовый слой: организация, отделы, пользователи, участия, доступ, аудит. +"""Базовый слой: организация, группы, пользователи, участия, аудит. Использует существующие идемпотентные хелперы (``ensure_*``). """ @@ -6,18 +6,15 @@ from __future__ import annotations from hub_platform.ai.knowledge_categories import ensure_uncategorized_category -from hub_platform.identity.access_defaults import ensure_system_assignment from hub_platform.identity.audit import record_audit_event from hub_platform.identity.demo_seed import manifest from hub_platform.identity.demo_seed.refs import DemoRefs +from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember from hub_platform.identity.models import ( - Department, - EmployeeRole, HumanUser, Organization, OrganizationMembership, ) -from hub_platform.identity.system_departments import ensure_system_departments from hub_platform.subscriptions.default_subscription import ensure_default_subscription from hub_platform.tenancy.context import TenantContext @@ -36,15 +33,11 @@ def load(context: TenantContext, refs: DemoRefs) -> None: ) refs.organization = organization - departments = ensure_system_departments(organization) - refs.departments.update(departments) - for item in data.get("extraDepartments", []): - dept, _ = Department.objects.get_or_create( - organization=organization, - code=item["code"], - defaults={"name": item["name"]}, + for item in data.get("groups", []): + group, _ = EmployeeGroup.objects.get_or_create( + organization=organization, name=item["name"] ) - refs.departments[item["code"]] = dept + refs.groups[item["key"]] = group ensure_uncategorized_category(organization) slots = data.get("aiAgentSlots", 5) @@ -65,7 +58,6 @@ def load(context: TenantContext, refs: DemoRefs) -> None: def _ensure_users_and_memberships(context: TenantContext, refs: DemoRefs, data: dict) -> None: organization = refs.organization - owner_membership = None for item in data["accounts"]: email = HumanUser.objects.normalize_email(item["email"]) @@ -82,25 +74,19 @@ def _ensure_users_and_memberships(context: TenantContext, refs: DemoRefs, data: user.save(update_fields=["password"]) refs.users[item["key"]] = user - role = item["role"] membership, _ = OrganizationMembership.objects.get_or_create( user=user, organization=organization, defaults={ - "role": role, + "role": item["role"], "position_title": item.get("positionTitle", ""), "phone": item.get("phone", ""), - "primary_department": refs.departments.get(item.get("department", "")), }, ) refs.memberships[item["key"]] = membership - if role == EmployeeRole.OWNER: - owner_membership = membership - elif item.get("systemProfile") and owner_membership is not None: - ensure_system_assignment( - employee=membership, - assigned_by=owner_membership, - department=refs.departments[item["department"]], - profile_name=item["systemProfile"], + group = refs.groups.get(item.get("group", "")) + if group is not None: + EmployeeGroupMember.objects.get_or_create( + organization=organization, group=group, employee=membership ) diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py b/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py index 43226ce..e61138f 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py @@ -122,7 +122,6 @@ def _ensure_notifications(refs: DemoRefs, items: list[dict]) -> None: organization=refs.organization, dedup_key=dedup_key, defaults={ - "department": refs.departments.get(item.get("department")), "type": item["type"], "level": item.get("level", NotificationLevel.INFO), "title": item["title"], diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/support.py b/apps/backend/hub_platform/identity/demo_seed/loaders/support.py index c61058e..aca219e 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/support.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/support.py @@ -79,13 +79,11 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N if not portal_data: return organization = refs.organization - support_dept = refs.departments["support"] portal, portal_created = SupportPortal.objects.get_or_create( slug=portal_data["slug"], defaults={ "organization": organization, - "department": support_dept, "name": portal_data["name"], "default_locale": portal_data.get("locale", "ru"), "status": portal_data.get("status", PortalStatus.PUBLISHED), diff --git a/apps/backend/hub_platform/identity/demo_seed/refs.py b/apps/backend/hub_platform/identity/demo_seed/refs.py index 044eed9..13af9fd 100644 --- a/apps/backend/hub_platform/identity/demo_seed/refs.py +++ b/apps/backend/hub_platform/identity/demo_seed/refs.py @@ -19,7 +19,7 @@ class DemoRefs: organization: object | None = None subscription: object | None = None - departments: dict[str, object] = field(default_factory=dict) + groups: dict[str, object] = field(default_factory=dict) memberships: dict[str, object] = field(default_factory=dict) users: dict[str, object] = field(default_factory=dict) products: dict[str, object] = field(default_factory=dict) diff --git a/apps/backend/hub_platform/identity/employee_support.py b/apps/backend/hub_platform/identity/employee_support.py index 60c1c9c..6e29bbd 100644 --- a/apps/backend/hub_platform/identity/employee_support.py +++ b/apps/backend/hub_platform/identity/employee_support.py @@ -2,7 +2,6 @@ from rest_framework.request import Request from hub_platform.identity.governance import employee_management_flags from hub_platform.identity.models import AuditEvent, OrganizationMembership -from hub_platform.identity.sessions import count_user_sessions def employee_payload( @@ -11,6 +10,10 @@ def employee_payload( *, include_detail: bool = False, ) -> dict[str, object]: + groups = [ + {"id": link.group_id, "name": link.group.name} + for link in profile.group_links.select_related("group").order_by("group__name") + ] payload: dict[str, object] = { "id": profile.user_id, "email": profile.user.email, @@ -18,8 +21,7 @@ def employee_payload( "role": profile.role, "positionTitle": profile.position_title, "phone": profile.phone, - "department": profile.primary_department.code if profile.primary_department else None, - "departmentName": profile.primary_department.name if profile.primary_department else None, + "groups": groups, "createdAt": profile.created_at.isoformat(), "lastLogin": profile.user.last_login.isoformat() if profile.user.last_login else None, "isActive": profile.user.is_active, @@ -27,31 +29,14 @@ def employee_payload( "mustChangePassword": profile.user.must_change_password, "totpRequired": profile.totp_required, "totpEnabled": profile.user.totp_enabled, - "accessAssignments": [ - { - "id": assignment.id, - "profileId": assignment.access_profile_id, - "profileName": assignment.access_profile.name, - "scopeType": assignment.scope_type, - "departmentId": assignment.department_id, - "departmentCode": assignment.department.code if assignment.department_id else None, - "departmentName": assignment.department.name if assignment.department_id else None, - "capabilities": sorted( - assignment.access_profile.capability_links.values_list( - "capability_code", flat=True - ) - ), - } - for assignment in profile.access_assignments.filter( - revoked_at__isnull=True, access_profile__is_active=True - ).select_related("access_profile", "department") - ], } # Backend — источник истины для того, какие действия над сотрудником доступны - # запрашивающему (ADR-HUB-0027): фронтенд скрывает недоступное. + # запрашивающему (SPEC-HUB-0031 §3): фронтенд скрывает недоступное. if actor is not None: payload["permissions"] = employee_management_flags(actor, profile) if include_detail: + from hub_platform.identity.sessions import count_user_sessions + payload["activeSessionCount"] = count_user_sessions(profile.user_id) payload["auditEvents"] = [ { @@ -72,8 +57,8 @@ def get_owned_profile(request: Request, user_id: int) -> OrganizationMembership owner_profile = request.tenant_context.membership try: return ( - OrganizationMembership.objects.select_related("user", "primary_department") - .prefetch_related("access_assignments__access_profile__capability_links") + OrganizationMembership.objects.select_related("user") + .prefetch_related("group_links__group") .get(user_id=user_id, organization=owner_profile.organization) ) except OrganizationMembership.DoesNotExist: diff --git a/apps/backend/hub_platform/identity/employee_urls.py b/apps/backend/hub_platform/identity/employee_urls.py index 247b8f1..f0e635d 100644 --- a/apps/backend/hub_platform/identity/employee_urls.py +++ b/apps/backend/hub_platform/identity/employee_urls.py @@ -1,7 +1,6 @@ from django.urls import path from hub_platform.identity import ( - access_views, employee_security_views, employee_views, ownership_views, @@ -19,6 +18,4 @@ urlpatterns = [ path("/block/", employee_security_views.EmployeeBlockView.as_view(), name="employee-block"), path("/unblock/", employee_security_views.EmployeeUnblockView.as_view(), name="employee-unblock"), path("/transfer-ownership/", ownership_views.OwnershipTransferView.as_view(), name="employee-transfer-ownership"), - path("/access-assignments/", access_views.EmployeeAccessAssignmentView.as_view(), name="employee-access-assignment"), - path("/access-assignments//", access_views.EmployeeAccessAssignmentRevokeView.as_view(), name="employee-access-assignment-revoke"), ] diff --git a/apps/backend/hub_platform/identity/employee_validation.py b/apps/backend/hub_platform/identity/employee_validation.py index b26b0e4..b0295d8 100644 --- a/apps/backend/hub_platform/identity/employee_validation.py +++ b/apps/backend/hub_platform/identity/employee_validation.py @@ -5,8 +5,7 @@ from hub_platform.identity.audit import record_audit_event from hub_platform.identity.models import ( POSITION_TITLE_MAX_LENGTH, AuditResult, - Department, - DepartmentStatus, + EmployeeGroup, EmployeeRole, OrganizationMembership, ) @@ -23,17 +22,17 @@ def clean_position_title(raw: object) -> tuple[str, str | None]: return value, None -def resolve_department(organization, code: str) -> tuple[Department | None, str | None]: - code = (code or "").strip() - if not code: +def resolve_groups(organization, raw: object) -> tuple[list[EmployeeGroup] | None, str | None]: + """Валидирует список id групп из запроса; None на входе — «не менять».""" + if raw is None: return None, None - try: - department = Department.objects.get( - organization=organization, code=code, status=DepartmentStatus.ACTIVE - ) - except Department.DoesNotExist: - return None, "Department not found" - return department, None + if not isinstance(raw, list) or any(not isinstance(item, int) for item in raw): + return None, "groupIds must be a list of ids" + requested = list(dict.fromkeys(raw)) + groups = list(EmployeeGroup.objects.filter(organization=organization, id__in=requested)) + if len(groups) != len(requested): + return None, "Group not found" + return groups, None def deny_employee_action( diff --git a/apps/backend/hub_platform/identity/employee_views.py b/apps/backend/hub_platform/identity/employee_views.py index b0a767b..5e74423 100644 --- a/apps/backend/hub_platform/identity/employee_views.py +++ b/apps/backend/hub_platform/identity/employee_views.py @@ -1,5 +1,4 @@ -from django.core.exceptions import ValidationError -from django.db import IntegrityError, transaction +from django.db import transaction from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -7,23 +6,32 @@ from rest_framework.views import APIView from hub_platform.events.services import DomainEvent, enqueue_event from hub_platform.identity.audit import record_audit_event -from hub_platform.identity.access_services import create_access_assignment from hub_platform.identity.employee_support import employee_payload, get_owned_profile from hub_platform.identity.employee_validation import ( ASSIGNABLE_ROLES, clean_position_title, deny_employee_action, - resolve_department, + resolve_groups, ) from hub_platform.identity.event_handlers import INITIAL_ACCESS_REQUESTED from hub_platform.identity.governance import EmployeeAction, can_create_role, can_manage_employee +from hub_platform.identity.group_models import EmployeeGroupMember from hub_platform.identity.models import EmployeeRole, HumanUser, OrganizationMembership -from hub_platform.identity.policy import ( - ResourceScope, - accessible_department_ids, - authorize, - has_capability_any_scope, -) +from hub_platform.identity.policy import has_capability_any_scope + + +def _set_groups(profile: OrganizationMembership, groups) -> None: + profile.group_links.all().delete() + EmployeeGroupMember.objects.bulk_create( + [ + EmployeeGroupMember( + organization_id=profile.organization_id, + group=group, + employee=profile, + ) + for group in groups + ] + ) class EmployeeListView(APIView): @@ -34,13 +42,10 @@ class EmployeeListView(APIView): if not has_capability_any_scope(actor, "employees.view"): return Response({"detail": "Not allowed"}, status=403) employees = ( - OrganizationMembership.objects.select_related("user", "primary_department") - .prefetch_related("access_assignments__access_profile__capability_links") + OrganizationMembership.objects.select_related("user") + .prefetch_related("group_links__group") .filter(organization=actor.organization) ) - department_ids = accessible_department_ids(actor, "employees.view") - if department_ids is not None: - employees = employees.filter(primary_department_id__in=department_ids) return Response( { "items": [ @@ -64,7 +69,6 @@ class EmployeeCreateView(APIView): provided_password = str(body.get("temporaryPassword", "")) position_title, position_error = clean_position_title(body.get("positionTitle")) requested_role = str(body.get("role", EmployeeRole.EMPLOYEE)) - assignments = body.get("accessAssignments", []) if requested_role not in ASSIGNABLE_ROLES: return Response({"detail": "Invalid role"}, status=400) @@ -76,12 +80,6 @@ class EmployeeCreateView(APIView): return Response({"detail": "Email is required"}, status=400) if not full_name: return Response({"detail": "Full name is required"}, status=400) - if not isinstance(assignments, list) or any( - not isinstance(assignment, dict) for assignment in assignments - ): - return Response({"detail": "accessAssignments must be a list"}, status=400) - if requested_role == EmployeeRole.ADMIN and assignments: - return Response({"detail": "ADMIN access is defined by the system role"}, status=400) if position_error: return Response({"detail": position_error}, status=400) if provided_password: @@ -89,11 +87,9 @@ class EmployeeCreateView(APIView): if HumanUser.objects.filter(email=email).exists(): return Response({"detail": "Email is already used"}, status=400) - department, department_error = resolve_department( - actor.organization, str(body.get("department", "")).strip() - ) - if department_error: - return Response({"detail": department_error}, status=400) + groups, groups_error = resolve_groups(actor.organization, body.get("groupIds")) + if groups_error: + return Response({"detail": groups_error}, status=400) user = HumanUser.objects.create_user( email=email, @@ -109,14 +105,9 @@ class EmployeeCreateView(APIView): role=requested_role, position_title=position_title, phone=phone, - primary_department=department, ) - try: - for assignment in assignments: - create_access_assignment(actor=actor, employee=profile, payload=assignment) - except (ValidationError, IntegrityError) as error: - transaction.set_rollback(True) - return Response({"detail": str(error)}, status=400) + if groups: + _set_groups(profile, groups) record_audit_event( action="identity.employee_created", actor=request.user, @@ -146,8 +137,7 @@ class EmployeeDetailView(APIView): profile = get_owned_profile(request, user_id) if profile is None: return Response({"detail": "Employee not found"}, status=404) - scope = ResourceScope(profile.organization_id, profile.primary_department_id) - if not authorize(actor, "employees.view", scope): + if not has_capability_any_scope(actor, "employees.view"): return Response({"detail": "Employee not found"}, status=404) return Response({"employee": employee_payload(profile, actor, include_detail=True)}) @@ -171,9 +161,6 @@ class EmployeeUpdateView(APIView): position_title, position_error = clean_position_title( body.get("positionTitle", profile.position_title) ) - current_department_code = ( - profile.primary_department.code if profile.primary_department else "" - ) requested_role = str(body.get("role", profile.role)) if not full_name: @@ -192,18 +179,13 @@ class EmployeeUpdateView(APIView): if not can_manage_employee(actor, profile, EmployeeAction.CHANGE_ROLE): return deny_employee_action(request, profile, EmployeeAction.CHANGE_ROLE) - department, department_error = resolve_department( - profile.organization, str(body.get("department", current_department_code)) - ) - if department_error: - return Response({"detail": department_error}, status=400) - placement_changing = ( - department.id if department else None - ) != profile.primary_department_id - if placement_changing and not can_manage_employee( - actor, profile, EmployeeAction.CHANGE_PLACEMENT + groups, groups_error = resolve_groups(profile.organization, body.get("groupIds")) + if groups_error: + return Response({"detail": groups_error}, status=400) + if groups is not None and not can_manage_employee( + actor, profile, EmployeeAction.CHANGE_GROUPS ): - return deny_employee_action(request, profile, EmployeeAction.CHANGE_PLACEMENT) + return deny_employee_action(request, profile, EmployeeAction.CHANGE_GROUPS) profile.user.full_name = full_name profile.user.email = email @@ -211,15 +193,9 @@ class EmployeeUpdateView(APIView): profile.phone = phone profile.position_title = position_title profile.role = requested_role - profile.primary_department = department - profile.save( - update_fields=[ - "phone", - "position_title", - "role", - "primary_department", - ] - ) + profile.save(update_fields=["phone", "position_title", "role"]) + if groups is not None: + _set_groups(profile, groups) record_audit_event( action="identity.employee_updated", @@ -239,14 +215,14 @@ class EmployeeUpdateView(APIView): payload={"role": profile.role}, request=request, ) - if placement_changing: + if groups is not None: record_audit_event( - action="identity.employee_placement_changed", + action="identity.employee_groups_changed", actor=request.user, organization=profile.organization, object_type="HumanUser", object_id=str(profile.user_id), - payload={"department": department.code if department else None}, + payload={"groupIds": [group.id for group in groups]}, request=request, ) return Response({"employee": employee_payload(profile, actor)}) diff --git a/apps/backend/hub_platform/identity/governance.py b/apps/backend/hub_platform/identity/governance.py index 57c93d8..b85d650 100644 --- a/apps/backend/hub_platform/identity/governance.py +++ b/apps/backend/hub_platform/identity/governance.py @@ -1,13 +1,11 @@ -"""Target-aware governance policy для управления сотрудниками (ADR-HUB-0027 этап 2). +"""Target-aware governance для управления сотрудниками (SPEC-HUB-0031 §3). -Единая точка истины «кто может управлять каким сотрудником». Матрица SPEC-HUB-0016 §8: +OWNER и ADMIN идентичны по правам: оба управляют любыми сотрудниками, включая +других администраторов. Отличия ровно два: -- OWNER управляет ADMIN и EMPLOYEE; операции над самим OWNER — только ownership flow. -- ADMIN управляет только EMPLOYEE; не трогает OWNER и других ADMIN никаким действием. -- EMPLOYEE не управляет сотрудниками. - -`change_role` и `transfer_ownership` доступны только OWNER. Защищённые действия ADMIN -не обходит через вспомогательные операции (block/reset/terminate/placement/access). +- владельца нельзя удалить и заблокировать (и нельзя сменить ему роль — + единственный путь: передача владения); +- передача владения доступна только самому владельцу. """ from __future__ import annotations @@ -20,8 +18,7 @@ class EmployeeAction: CREATE = "create" UPDATE_PROFILE = "update_profile" CHANGE_ROLE = "change_role" - CHANGE_PLACEMENT = "change_placement" - CHANGE_ACCESS = "change_access" + CHANGE_GROUPS = "change_groups" BLOCK = "block" UNBLOCK = "unblock" RESET_PASSWORD = "reset_password" @@ -30,16 +27,27 @@ class EmployeeAction: TRANSFER_OWNERSHIP = "transfer_ownership" -# Действия, которые OWNER выполняет над обычными и привилегированными целями, а ADMIN — -# только над EMPLOYEE. change_role/transfer_ownership обрабатываются отдельно (owner-only). -_TARGET_ACTIONS = frozenset( +# Действия, запрещённые над владельцем для всех (SPEC-HUB-0031 §3); +# смена его роли возможна только через ownership flow. +_OWNER_PROTECTED_ACTIONS = frozenset( + { + EmployeeAction.CHANGE_ROLE, + EmployeeAction.BLOCK, + EmployeeAction.UNBLOCK, + EmployeeAction.DELETE, + } +) + +_MANAGED_ACTIONS = frozenset( { EmployeeAction.VIEW, EmployeeAction.UPDATE_PROFILE, - EmployeeAction.CHANGE_PLACEMENT, - EmployeeAction.CHANGE_ACCESS, + EmployeeAction.CHANGE_ROLE, + EmployeeAction.CHANGE_GROUPS, EmployeeAction.BLOCK, EmployeeAction.UNBLOCK, + EmployeeAction.RESET_PASSWORD, + EmployeeAction.TERMINATE_SESSIONS, EmployeeAction.DELETE, } ) @@ -54,18 +62,10 @@ def _is_active_manager(actor: OrganizationMembership | None) -> bool: def can_create_role(actor: OrganizationMembership | None, new_role: str) -> bool: - """Кого actor вправе создать. OWNER — ADMIN или EMPLOYEE; ADMIN — только EMPLOYEE. - - Второй OWNER через обычный create не создаётся (инвариант ровно одного владельца).""" + """OWNER и ADMIN создают ADMIN или EMPLOYEE; второй OWNER не создаётся.""" if not _is_active_manager(actor): return False - if new_role == EmployeeRole.OWNER: - return False - if new_role == EmployeeRole.ADMIN: - return actor.role == EmployeeRole.OWNER - if new_role == EmployeeRole.EMPLOYEE: - return True - return False + return new_role in {EmployeeRole.ADMIN, EmployeeRole.EMPLOYEE} def can_manage_employee( @@ -73,15 +73,12 @@ def can_manage_employee( target: OrganizationMembership | None, action: str, ) -> bool: - """Может ли actor выполнить action над target (SPEC-HUB-0016 §8). - - Порядок проверки повторяет ADR-HUB-0027: активный менеджер → одна организация → - роль target → owner-only для смены роли и передачи владения.""" + """Может ли actor выполнить action над target (SPEC-HUB-0031 §3).""" if not _is_active_manager(actor): return False if action == EmployeeAction.TRANSFER_OWNERSHIP: - # Передаёт владение только действующий OWNER; target обязателен и той же организации. + # Передаёт владение только действующий OWNER; target — не владелец. return ( actor.role == EmployeeRole.OWNER and target is not None @@ -91,21 +88,10 @@ def can_manage_employee( if target is None or target.organization_id != actor.organization_id: return False - - # Владельца не трогает обычными действиями никто — только ownership flow выше. - if target.role == EmployeeRole.OWNER: + if action not in _MANAGED_ACTIONS: return False - - # Смена роли (в т.ч. назначение ADMIN) — исключительно OWNER. - if action == EmployeeAction.CHANGE_ROLE: - return actor.role == EmployeeRole.OWNER - - if action not in _TARGET_ACTIONS: + if target.role == EmployeeRole.OWNER and action in _OWNER_PROTECTED_ACTIONS: return False - - # Другого ADMIN изменяет только OWNER; EMPLOYEE — любой активный менеджер. - if target.role == EmployeeRole.ADMIN: - return actor.role == EmployeeRole.OWNER return True @@ -113,14 +99,13 @@ def employee_management_flags( actor: OrganizationMembership | None, target: OrganizationMembership, ) -> dict[str, bool]: - """Флаги доступных действий над target для actor — backend как источник истины - для скрытия недоступных действий во фронтенде (ADR-HUB-0027).""" + """Флаги доступных действий над target — backend как источник истины + для скрытия недоступных действий во фронтенде.""" return { "canView": can_manage_employee(actor, target, EmployeeAction.VIEW), "canUpdateProfile": can_manage_employee(actor, target, EmployeeAction.UPDATE_PROFILE), "canChangeRole": can_manage_employee(actor, target, EmployeeAction.CHANGE_ROLE), - "canChangePlacement": can_manage_employee(actor, target, EmployeeAction.CHANGE_PLACEMENT), - "canChangeAccess": can_manage_employee(actor, target, EmployeeAction.CHANGE_ACCESS), + "canChangeGroups": can_manage_employee(actor, target, EmployeeAction.CHANGE_GROUPS), "canBlock": can_manage_employee(actor, target, EmployeeAction.BLOCK), "canUnblock": can_manage_employee(actor, target, EmployeeAction.UNBLOCK), "canResetPassword": can_manage_employee(actor, target, EmployeeAction.RESET_PASSWORD), diff --git a/apps/backend/hub_platform/identity/group_models.py b/apps/backend/hub_platform/identity/group_models.py new file mode 100644 index 0000000..78c783f --- /dev/null +++ b/apps/backend/hub_platform/identity/group_models.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models.functions import Lower + +from hub_platform.identity.models import Organization, OrganizationMembership +from hub_platform.tenancy.models import TenantRelationModel + +# Настраиваемые группы сотрудников (ADR-HUB-0043): граница видимости диалогов +# и ничего больше — без прав, знаний, должностей и иерархии. Организация сама +# решает, какие группы ей нужны; групп может не быть вообще. + + +class EmployeeGroup(models.Model): + organization = models.ForeignKey( + Organization, on_delete=models.PROTECT, related_name="employee_groups" + ) + name = models.CharField(max_length=120) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["name"] + constraints = [ + models.UniqueConstraint( + Lower("name"), "organization", name="uniq_employee_group_org_name_ci" + ) + ] + + def clean(self) -> None: + self.name = self.name.strip() + if not self.name: + raise ValidationError({"name": "Group name is required"}) + + def save(self, *args, **kwargs) -> None: + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self) -> str: + return f"{self.organization.slug}/{self.name}" + + +class EmployeeGroupMember(TenantRelationModel): + tenant_relation_fields = ("group", "employee") + group = models.ForeignKey( + EmployeeGroup, on_delete=models.CASCADE, related_name="member_links" + ) + employee = models.ForeignKey( + OrganizationMembership, on_delete=models.CASCADE, related_name="group_links" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["group", "employee"], name="uniq_employee_group_member" + ) + ] + + def save(self, *args, **kwargs) -> None: + self.validate_tenant_relations() + super().save(*args, **kwargs) + + +def member_group_ids(membership: OrganizationMembership) -> set[int]: + """Группы сотрудника; используется политикой видимости диалогов.""" + return set( + EmployeeGroupMember.objects.filter(employee=membership).values_list( + "group_id", flat=True + ) + ) diff --git a/apps/backend/hub_platform/identity/group_views.py b/apps/backend/hub_platform/identity/group_views.py new file mode 100644 index 0000000..862e099 --- /dev/null +++ b/apps/backend/hub_platform/identity/group_views.py @@ -0,0 +1,155 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.db.models import Count +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.api.permissions import HasCapability +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember +from hub_platform.identity.models import OrganizationMembership + +# Группы сотрудников (ADR-HUB-0043): имя + состав, только граница видимости +# диалогов. Управляют OWNER/ADMIN; сотрудник видит свои группы в session payload. + + +def _group_payload(group: EmployeeGroup, member_count: int | None = None) -> dict[str, object]: + if member_count is None: + member_count = group.member_links.count() + return { + "id": group.id, + "name": group.name, + "memberCount": member_count, + "memberIds": sorted( + group.member_links.values_list("employee__user_id", flat=True) + ), + "createdAt": group.created_at.isoformat(), + } + + +def _resolve_members(organization, raw) -> tuple[list[OrganizationMembership] | None, str | None]: + if raw is None: + return None, None + if not isinstance(raw, list) or any(not isinstance(item, int) for item in raw): + return None, "memberIds must be a list of user ids" + requested = list(dict.fromkeys(raw)) + members = list( + OrganizationMembership.objects.filter( + organization=organization, user_id__in=requested + ) + ) + if len(members) != len(requested): + return None, "Employee not found" + return members, None + + +class GroupListView(APIView): + permission_classes = [HasCapability] + required_capabilities = {"GET": "employees.view", "POST": "groups.manage"} + + def get(self, request: Request) -> Response: + groups = ( + EmployeeGroup.objects.filter(organization=request.tenant_context.organization) + .annotate(member_count=Count("member_links")) + .order_by("name") + ) + return Response( + {"items": [_group_payload(group, group.member_count) for group in groups]} + ) + + @transaction.atomic + def post(self, request: Request) -> Response: + organization = request.tenant_context.organization + name = str(request.data.get("name", "")).strip() + members, members_error = _resolve_members(organization, request.data.get("memberIds")) + if members_error: + return Response({"detail": members_error}, status=400) + try: + group = EmployeeGroup.objects.create(organization=organization, name=name) + except (ValidationError, IntegrityError): + return Response({"detail": "Группа с таким именем уже есть"}, status=400) + if members: + EmployeeGroupMember.objects.bulk_create( + [ + EmployeeGroupMember( + organization=organization, group=group, employee=member + ) + for member in members + ] + ) + record_audit_event( + action="identity.group_created", + actor=request.user, + organization=organization, + object_type="EmployeeGroup", + object_id=str(group.id), + payload={"name": group.name}, + request=request, + ) + return Response({"group": _group_payload(group)}, status=201) + + +class GroupDetailView(APIView): + permission_classes = [HasCapability] + required_capabilities = {"PATCH": "groups.manage", "DELETE": "groups.manage"} + + def _group(self, request: Request, group_id: int) -> EmployeeGroup | None: + return EmployeeGroup.objects.filter( + organization=request.tenant_context.organization, id=group_id + ).first() + + @transaction.atomic + def patch(self, request: Request, group_id: int) -> Response: + group = self._group(request, group_id) + if group is None: + return Response({"detail": "Group not found"}, status=404) + if "name" in request.data: + group.name = str(request.data.get("name", "")).strip() + try: + group.save() + except (ValidationError, IntegrityError): + return Response({"detail": "Группа с таким именем уже есть"}, status=400) + members, members_error = _resolve_members( + group.organization, request.data.get("memberIds") + ) + if members_error: + return Response({"detail": members_error}, status=400) + if members is not None: + group.member_links.all().delete() + EmployeeGroupMember.objects.bulk_create( + [ + EmployeeGroupMember( + organization=group.organization, group=group, employee=member + ) + for member in members + ] + ) + record_audit_event( + action="identity.group_updated", + actor=request.user, + organization=group.organization, + object_type="EmployeeGroup", + object_id=str(group.id), + request=request, + ) + return Response({"group": _group_payload(group)}) + + @transaction.atomic + def delete(self, request: Request, group_id: int) -> Response: + group = self._group(request, group_id) + if group is None: + return Response({"detail": "Group not found"}, status=404) + # SET_NULL на каналах и диалогах: их диалоги становятся общими. + group_payload = _group_payload(group) + group.delete() + record_audit_event( + action="identity.group_deleted", + actor=request.user, + organization=request.tenant_context.organization, + object_type="EmployeeGroup", + object_id=str(group_id), + payload={"name": group_payload["name"]}, + request=request, + ) + return Response({"deleted": True}) diff --git a/apps/backend/hub_platform/identity/invitation_service.py b/apps/backend/hub_platform/identity/invitation_service.py index ae6fd61..1bc578c 100644 --- a/apps/backend/hub_platform/identity/invitation_service.py +++ b/apps/backend/hub_platform/identity/invitation_service.py @@ -138,7 +138,6 @@ def _ensure_owner_membership( defaults={ "role": EmployeeRole.OWNER, "position_title": "Владелец", - "primary_department": None, "totp_required": False, }, ) diff --git a/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py b/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py index 28c13b8..1353e20 100644 --- a/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py +++ b/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py @@ -22,6 +22,6 @@ class Command(BaseCommand): state = "created" if result.created_owner else "already_exists" self.stdout.write( self.style.SUCCESS( - f"OWNER {state}: {result.owner.email}; org={result.organization.slug}; department={result.sales_department.code}" + f"OWNER {state}: {result.owner.email}; org={result.organization.slug}; groups={result.operators_group.name},{result.support_group.name}" ) ) diff --git a/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py b/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py deleted file mode 100644 index c0e1b63..0000000 --- a/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py +++ /dev/null @@ -1,56 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError -from django.db.models import Count, Q - -from hub_platform.identity.models import EmployeeRole, Organization, OrganizationMembership - - -class Command(BaseCommand): - help = "Read-only verification report for the capability authorization cutover" - - def handle(self, *args, **options): - issues: list[str] = [] - - legacy_roles = OrganizationMembership.objects.filter(role="OPERATOR").count() - if legacy_roles: - issues.append(f"active legacy role rows: {legacy_roles}") - - missing_titles = OrganizationMembership.objects.filter( - Q(user__is_active=True) & (Q(position_title="") | Q(position_title__isnull=True)) - ).count() - if missing_titles: - issues.append(f"active employees without position title: {missing_titles}") - - invalid_owner_organizations = list( - Organization.objects.annotate( - owner_count=Count( - "memberships", filter=Q(memberships__role=EmployeeRole.OWNER) - ) - ) - .exclude(owner_count=1) - .values_list("id", flat=True) - ) - if invalid_owner_organizations: - issues.append(f"organizations with invalid owner count: {invalid_owner_organizations}") - - owners_with_department = OrganizationMembership.objects.filter( - role=EmployeeRole.OWNER, primary_department__isnull=False - ).count() - if owners_with_department: - issues.append(f"owners with primary department: {owners_with_department}") - - employees_without_access = OrganizationMembership.objects.filter( - role=EmployeeRole.EMPLOYEE, - user__is_active=True, - ).exclude( - access_assignments__revoked_at__isnull=True, - access_assignments__access_profile__is_active=True, - ) - self.stdout.write( - f"active employees without work assignments: {employees_without_access.count()}" - ) - for employee in employees_without_access.select_related("user"): - self.stdout.write(f" - {employee.user.email}") - - if issues: - raise CommandError("Authorization verification failed: " + "; ".join(issues)) - self.stdout.write(self.style.SUCCESS("Authorization verification passed")) diff --git a/apps/backend/hub_platform/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py b/apps/backend/hub_platform/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py new file mode 100644 index 0000000..c81a105 --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py @@ -0,0 +1,174 @@ +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +import django.db.models.deletion +import django.db.models.functions.text +from django.db import migrations, models + +# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и +# грантов, которые исходно раздавала tenancy/0003 (она при откате не +# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут +# читать identity_department и migration-тесты падают на старых состояниях. +# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при +# откате (операции разворачиваются в обратном порядке), когда таблицы уже +# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях +# тестами не используется, поэтому политики не восстанавливаем. +_RESTORE_GRANTS_SQL = "\n".join( + f""" +ALTER TABLE {table} OWNER TO custocrm_schema; +GRANT ALL ON {table} TO custocrm_schema; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO custocrm_runtime_app; +""" + for table in ( + "identity_department", + "identity_accessprofile", + "identity_accessprofilecapability", + "identity_employeeaccessassignment", + ) +) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0014_remove_knowledgedepartment_department_and_more'), + ('channels', '0006_remove_channel_department'), + ('identity', '0019_drop_sales_capabilities'), + ('notifications', '0008_remove_notification_department'), + ('products', '0012_delete_productdepartment'), + ('support_portals', '0008_remove_supportportal_department'), + ] + + operations = [ + migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL), + migrations.CreateModel( + name='EmployeeGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='EmployeeGroupMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + # Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе + # state хранит constraint на несуществующее поле и обратная миграция + # (reverse DeleteModel в migration-тестах) падает при create_model. + migrations.RemoveConstraint( + model_name='accessprofile', + name='uniq_access_profile_org_name_ci', + ), + migrations.RemoveConstraint( + model_name='accessprofilecapability', + name='uniq_access_profile_capability', + ), + migrations.RemoveConstraint( + model_name='department', + name='uniq_department_org_code', + ), + migrations.RemoveConstraint( + model_name='employeeaccessassignment', + name='access_assignment_scope_department', + ), + migrations.RemoveConstraint( + model_name='employeeaccessassignment', + name='uniq_active_org_access_assignment', + ), + migrations.RemoveConstraint( + model_name='employeeaccessassignment', + name='uniq_active_dept_access_assignment', + ), + migrations.RemoveField( + model_name='accessprofile', + name='organization', + ), + migrations.RemoveField( + model_name='accessprofilecapability', + name='access_profile', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='access_profile', + ), + migrations.RemoveField( + model_name='accessprofilecapability', + name='organization', + ), + migrations.RemoveField( + model_name='department', + name='organization', + ), + # Сначала снимаем constraint, зависящий от primary_department: дроп + # колонки удалил бы его каскадно и RemoveConstraint ниже упал бы. + migrations.RemoveConstraint( + model_name='organizationmembership', + name='owner_is_company_level', + ), + migrations.RemoveField( + model_name='organizationmembership', + name='primary_department', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='department', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='assigned_by', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='employee', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='organization', + ), + migrations.AddField( + model_name='employeegroup', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'), + ), + migrations.AddField( + model_name='employeegroupmember', + name='employee', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'), + ), + migrations.AddField( + model_name='employeegroupmember', + name='group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'), + ), + migrations.AddField( + model_name='employeegroupmember', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.DeleteModel( + name='AccessProfile', + ), + migrations.DeleteModel( + name='AccessProfileCapability', + ), + migrations.DeleteModel( + name='Department', + ), + migrations.DeleteModel( + name='EmployeeAccessAssignment', + ), + migrations.AddConstraint( + model_name='employeegroup', + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'), + ), + migrations.AddConstraint( + model_name='employeegroupmember', + constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'), + ), + ] diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/hub_platform/identity/models.py index 2592745..d87f051 100644 --- a/apps/backend/hub_platform/identity/models.py +++ b/apps/backend/hub_platform/identity/models.py @@ -115,32 +115,7 @@ class Organization(models.Model): super().save(*args, **kwargs) -class DepartmentStatus(models.TextChoices): - ACTIVE = "ACTIVE", "Active" - DISABLED = "DISABLED", "Disabled" - - -class Department(models.Model): - organization = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="departments") - code = models.SlugField(max_length=64) - name = models.CharField(max_length=255) - status = models.CharField( - max_length=32, - choices=DepartmentStatus.choices, - default=DepartmentStatus.ACTIVE, - ) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - constraints = [ - models.UniqueConstraint(fields=["organization", "code"], name="uniq_department_org_code") - ] - - def __str__(self) -> str: - return f"{self.organization.slug}/{self.code}" - - -# ADR-HUB-0027 / SPEC-HUB-0016 §5: лимит должности задаётся backend-константой. +# SPEC-HUB-0016 §5: лимит должности задаётся backend-константой. POSITION_TITLE_MAX_LENGTH = 120 @@ -166,15 +141,6 @@ class OrganizationMembership(models.Model): # Пустая строка допускается на уровне БД только для legacy-записей до backfill. position_title = models.CharField(max_length=POSITION_TITLE_MAX_LENGTH, blank=True, default="") phone = models.CharField(max_length=32, blank=True) - # Основной отдел описывает оргструктуру, но не выдаёт прав (ADR-HUB-0027). - # null = сотрудник на верхнем уровне компании; OWNER всегда на уровне компании. - primary_department = models.ForeignKey( - Department, - on_delete=models.PROTECT, - related_name="memberships", - null=True, - blank=True, - ) totp_required = models.BooleanField(default=False) blocked_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) @@ -187,12 +153,7 @@ class OrganizationMembership(models.Model): fields=["user", "organization"], name="uniq_membership_user_organization", ), - # OWNER всегда на уровне компании (ADR-HUB-0027, инварианты размещения). - models.CheckConstraint( - condition=~Q(role=EmployeeRole.OWNER) | Q(primary_department__isnull=True), - name="owner_is_company_level", - ), - # В организации ровно один владелец (ADR-HUB-0027). + # В организации ровно один владелец (SPEC-HUB-0031 §3). models.UniqueConstraint( fields=["organization"], condition=Q(role=EmployeeRole.OWNER), @@ -258,12 +219,11 @@ class AuditEvent(models.Model): return f"{self.action}:{self.result}" -# Django imports only models.py by convention. Re-export access models after the core +# Django imports only models.py by convention. Re-export related models after the core # identity entities are defined so they are registered without growing this file. -from hub_platform.identity.access_models import ( # noqa: E402, F401 - AccessProfile, - AccessProfileCapability, - EmployeeAccessAssignment, +from hub_platform.identity.group_models import ( # noqa: E402, F401 + EmployeeGroup, + EmployeeGroupMember, ) from hub_platform.identity.invitation_models import ( # noqa: E402, F401 OrganizationInvitation, diff --git a/apps/backend/hub_platform/identity/ownership_views.py b/apps/backend/hub_platform/identity/ownership_views.py index ec107cd..9332169 100644 --- a/apps/backend/hub_platform/identity/ownership_views.py +++ b/apps/backend/hub_platform/identity/ownership_views.py @@ -9,7 +9,6 @@ from hub_platform.identity.employee_support import employee_payload from hub_platform.identity.employee_validation import ( ASSIGNABLE_ROLES, deny_employee_action, - resolve_department, ) from hub_platform.identity.governance import EmployeeAction, can_manage_employee from hub_platform.identity.models import EmployeeRole, OrganizationMembership @@ -22,13 +21,13 @@ class OwnershipTransferView(APIView): def post(self, request: Request, user_id: int) -> Response: actor = ( OrganizationMembership.objects.select_for_update(of=("self",)) - .select_related("user", "primary_department") + .select_related("user") .get(pk=request.tenant_context.membership.pk) ) try: target = ( OrganizationMembership.objects.select_for_update(of=("self",)) - .select_related("user", "primary_department") + .select_related("user") .get(user_id=user_id, organization=actor.organization) ) except OrganizationMembership.DoesNotExist: @@ -43,20 +42,10 @@ class OwnershipTransferView(APIView): if previous_owner_role not in ASSIGNABLE_ROLES: return Response({"detail": "Previous owner role must be ADMIN or EMPLOYEE"}, status=400) - previous_department = None - if previous_owner_role == EmployeeRole.EMPLOYEE: - previous_department, department_error = resolve_department( - actor.organization, str(request.data.get("previousOwnerDepartment", "")) - ) - if department_error: - return Response({"detail": department_error}, status=400) - actor.role = previous_owner_role - actor.primary_department = previous_department - actor.save(update_fields=["role", "primary_department"]) + actor.save(update_fields=["role"]) target.role = EmployeeRole.OWNER - target.primary_department = None - target.save(update_fields=["role", "primary_department"]) + target.save(update_fields=["role"]) record_audit_event( action="identity.ownership_transferred", diff --git a/apps/backend/hub_platform/identity/policy.py b/apps/backend/hub_platform/identity/policy.py index abc4f6f..68617ce 100644 --- a/apps/backend/hub_platform/identity/policy.py +++ b/apps/backend/hub_platform/identity/policy.py @@ -2,25 +2,22 @@ from __future__ import annotations from dataclasses import dataclass -from django.db.models import QuerySet - from hub_platform.identity.capabilities import ( - CAPABILITY_REGISTRY, - PROTECTED_CAPABILITIES, - ScopeType, - capability_spec, -) -from hub_platform.identity.models import ( - EmployeeAccessAssignment, - EmployeeRole, - OrganizationMembership, + ALL_CAPABILITIES, + EMPLOYEE_CAPABILITIES, + OWNER_ONLY_CAPABILITIES, ) +from hub_platform.identity.models import EmployeeRole, OrganizationMembership + +# Ролевая авторизация (SPEC-HUB-0031 §3, ADR-HUB-0043): OWNER и ADMIN идентичны +# (кроме ownership.transfer и невозможности удалить/заблокировать владельца — +# это проверяют employee-сервисы), EMPLOYEE ограничен чатом. Deny-by-default +# сохраняется; scope-модель и отделы упразднены. @dataclass(frozen=True, slots=True) class ResourceScope: organization_id: int - department_id: int | None = None def _active_membership(actor) -> OrganizationMembership | None: @@ -32,64 +29,29 @@ def _active_membership(actor) -> OrganizationMembership | None: return actor -def _assignments(profile: OrganizationMembership) -> QuerySet[EmployeeAccessAssignment]: - return ( - EmployeeAccessAssignment.objects.filter( - employee=profile, - revoked_at__isnull=True, - access_profile__is_active=True, - ) - .select_related("department", "access_profile") - .prefetch_related("access_profile__capability_links") - ) +def _role_capabilities(role: str) -> frozenset[str]: + if role == EmployeeRole.OWNER: + return ALL_CAPABILITIES + if role == EmployeeRole.ADMIN: + return ALL_CAPABILITIES - OWNER_ONLY_CAPABILITIES + if role == EmployeeRole.EMPLOYEE: + return EMPLOYEE_CAPABILITIES + return frozenset() def authorize(actor, capability: str, resource_scope: ResourceScope) -> bool: - """Deny-by-default capability and scope decision (SPEC-HUB-0017 §8).""" - try: - spec = capability_spec(capability) - except ValueError: - return False - + """Deny-by-default решение по роли внутри проверенной организации.""" profile = _active_membership(actor) if profile is None or profile.organization_id != resource_scope.organization_id: return False - if profile.role == EmployeeRole.OWNER: - return True - if profile.role == EmployeeRole.ADMIN: - return capability not in PROTECTED_CAPABILITIES - if profile.role != EmployeeRole.EMPLOYEE: - return False - - for assignment in _assignments(profile): - if assignment.scope_type not in spec.allowed_scopes: - continue - if assignment.scope_type == ScopeType.DEPARTMENT: - if resource_scope.department_id is None: - continue - if assignment.department_id != resource_scope.department_id: - continue - codes = { - link.capability_code for link in assignment.access_profile.capability_links.all() - } - if capability in codes: - return True - return False + return capability in _role_capabilities(profile.role) def has_capability_any_scope(actor, capability: str) -> bool: profile = _active_membership(actor) if profile is None: return False - if profile.role == EmployeeRole.OWNER: - return capability in CAPABILITY_REGISTRY - if profile.role == EmployeeRole.ADMIN: - return capability in CAPABILITY_REGISTRY and capability not in PROTECTED_CAPABILITIES - if profile.role != EmployeeRole.EMPLOYEE: - return False - return _assignments(profile).filter( - access_profile__capability_links__capability_code=capability - ).exists() + return capability in _role_capabilities(profile.role) def can_administer_access(actor) -> bool: @@ -97,79 +59,42 @@ def can_administer_access(actor) -> bool: return profile is not None and profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN} -def accessible_department_ids(actor, capability: str) -> set[int] | None: - """None means all departments in the actor organization; set() means no access.""" +def conversation_visibility(actor) -> dict | None: + """Видимость диалогов (ADR-HUB-0043 §4). + + None — без ограничений (OWNER/ADMIN). Иначе словарь для построения фильтра: + диалоги групп сотрудника + диалоги без группы + назначенные ему. + Пустой доступ (нет membership) — {"none": True}. + """ profile = _active_membership(actor) if profile is None: - return set() - if profile.role == EmployeeRole.OWNER: - return None if capability in CAPABILITY_REGISTRY else set() - if profile.role == EmployeeRole.ADMIN: - return None if capability not in PROTECTED_CAPABILITIES else set() - if profile.role != EmployeeRole.EMPLOYEE: - return set() + return {"none": True} + if profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}: + return None + from hub_platform.identity.group_models import member_group_ids - department_ids: set[int] = set() - for assignment in _assignments(profile).filter( - access_profile__capability_links__capability_code=capability - ): - if assignment.scope_type == ScopeType.ORGANIZATION: - return None - if assignment.department_id is not None: - department_ids.add(assignment.department_id) - return department_ids + return { + "group_ids": member_group_ids(profile), + "user_id": profile.user_id, + } def get_effective_access(actor) -> dict[str, object]: profile = _active_membership(actor) if profile is None: - return {"capabilities": [], "accessScopes": []} + return {"capabilities": [], "groups": []} + from hub_platform.identity.group_models import EmployeeGroupMember - if profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}: - codes = sorted( - code - for code in CAPABILITY_REGISTRY - if profile.role == EmployeeRole.OWNER or code not in PROTECTED_CAPABILITIES + groups = [ + {"id": link.group_id, "name": link.group.name} + for link in EmployeeGroupMember.objects.filter(employee=profile).select_related( + "group" ) - return { - "capabilities": codes, - "accessScopes": [ - { - "scopeType": ScopeType.ORGANIZATION, - "departmentId": None, - "departmentCode": None, - "capabilities": codes, - } - ], - } - - scope_codes: dict[tuple[str, int | None, str | None], set[str]] = {} - for assignment in _assignments(profile): - key = ( - assignment.scope_type, - assignment.department_id, - assignment.department.code if assignment.department_id else None, - ) - codes = scope_codes.setdefault(key, set()) - for link in assignment.access_profile.capability_links.all(): - spec = CAPABILITY_REGISTRY.get(link.capability_code) - if spec and spec.assignable and assignment.scope_type in spec.allowed_scopes: - codes.add(link.capability_code) - scopes = [ - { - "scopeType": scope_type, - "departmentId": department_id, - "departmentCode": department_code, - "capabilities": sorted(codes), - } - for (scope_type, department_id, department_code), codes in sorted( - scope_codes.items(), key=lambda item: (item[0][0], item[0][1] or 0) - ) - if codes ] + groups.sort(key=lambda item: str(item["name"])) return { - "capabilities": sorted({code for scope in scopes for code in scope["capabilities"]}), - "accessScopes": scopes, + "capabilities": sorted(_role_capabilities(profile.role)), + "groups": groups, } @@ -178,16 +103,7 @@ def scope_for_resource(resource) -> ResourceScope | None: organization_id = getattr(resource, "organization_id", None) if organization_id is None: return None - department_id = getattr(resource, "department_id", None) - if department_id is None: - channel = getattr(resource, "channel", None) - if channel is not None: - department_id = channel.department_id - if department_id is None: - conversation = getattr(resource, "conversation", None) - if conversation is not None: - department_id = conversation.channel.department_id - return ResourceScope(organization_id=organization_id, department_id=department_id) + return ResourceScope(organization_id=organization_id) def require_capability(actor, capability: str, resource) -> bool: diff --git a/apps/backend/hub_platform/identity/system_departments.py b/apps/backend/hub_platform/identity/system_departments.py deleted file mode 100644 index 89c58c9..0000000 --- a/apps/backend/hub_platform/identity/system_departments.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from hub_platform.identity.models import Department, Organization - -# System department codes (ADR-HUB-0022, SPEC-HUB-0010 §4.1). These were -# previously hardcoded strings in bootstrap.py / conversations routing. -# Centralised here so provisioning and future callers share one source of truth. -SALES_CODE = "sales" -SUPPORT_CODE = "support" - -_SYSTEM_DEPARTMENTS = ( - (SALES_CODE, "Продажи"), - (SUPPORT_CODE, "Поддержка"), -) - - -def ensure_system_departments(organization: Organization) -> dict[str, Department]: - """Create the sales and support departments for an organization if absent. - Returns a {code: Department} mapping. Both departments are always created - (SPEC-HUB-0021 §7): their use is governed by entitlement policy, not by the - presence of the Department row.""" - result: dict[str, Department] = {} - for code, name in _SYSTEM_DEPARTMENTS: - department, _ = Department.objects.get_or_create( - organization=organization, - code=code, - defaults={"name": name}, - ) - result[code] = department - return result diff --git a/apps/backend/hub_platform/identity/test_access_api.py b/apps/backend/hub_platform/identity/test_access_api.py deleted file mode 100644 index 097c504..0000000 --- a/apps/backend/hub_platform/identity/test_access_api.py +++ /dev/null @@ -1,248 +0,0 @@ -from django.test import TestCase -from hub_platform.testing import TenantAPIClient as APIClient - -from hub_platform.events.models import OutboxEvent -from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - Department, - EmployeeRole, - HumanUser, - Organization, - OrganizationMembership, -) - - -class AccessManagementApiTests(TestCase): - def setUp(self) -> None: - self.organization = Organization.objects.create(name="Example", slug="access-api") - self.sales = Department.objects.create( - organization=self.organization, code="sales", name="Sales" - ) - self.owner = self._employee("owner@access.test", EmployeeRole.OWNER) - self.admin = self._employee("admin@access.test", EmployeeRole.ADMIN) - self.employee = self._employee( - "employee@access.test", EmployeeRole.EMPLOYEE, self.sales - ) - self.other_admin = self._employee("other-admin@access.test", EmployeeRole.ADMIN) - self.client = APIClient() - self.client.force_authenticate(self.owner.user) - - def _employee( - self, email: str, role: str, department: Department | None = None - ) -> 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", - primary_department=department, - ) - - def test_profile_and_assignment_crud(self) -> None: - created = self.client.post( - "/api/v1/access-profiles/", - { - "name": "Sales reader", - "capabilities": ["customers.view", "conversations.view"], - }, - format="json", - ) - self.assertEqual(created.status_code, 201) - profile_id = created.json()["profile"]["id"] - self.assertEqual(created.json()["profile"]["allowedScopes"], ["DEPARTMENT", "ORGANIZATION"]) - - assigned = self.client.post( - f"/api/v1/employees/{self.employee.user_id}/access-assignments/", - { - "profileId": profile_id, - "scopeType": "DEPARTMENT", - "departmentId": self.sales.id, - }, - format="json", - ) - self.assertEqual(assigned.status_code, 201) - assignment_id = assigned.json()["assignment"]["id"] - - revoked = self.client.delete( - f"/api/v1/employees/{self.employee.user_id}/access-assignments/{assignment_id}/" - ) - self.assertEqual(revoked.status_code, 200) - self.assertIsNotNone(revoked.json()["assignment"]["revokedAt"]) - - def test_registry_exposes_protected_capabilities_as_read_only(self) -> None: - response = self.client.get("/api/v1/access-profiles/capabilities/") - - self.assertEqual(response.status_code, 200) - by_code = {item["code"]: item for item in response.json()["items"]} - self.assertTrue(by_code["ownership.transfer"]["protected"]) - self.assertFalse(by_code["ownership.transfer"]["assignable"]) - - def test_department_assignment_rejects_organization_only_profile(self) -> None: - profile = AccessProfile.objects.create( - organization=self.organization, name="Company reader" - ) - AccessProfileCapability.objects.create( - access_profile=profile, capability_code="company.view" - ) - - response = self.client.post( - f"/api/v1/employees/{self.employee.user_id}/access-assignments/", - { - "profileId": profile.id, - "scopeType": "DEPARTMENT", - "departmentId": self.sales.id, - }, - format="json", - ) - - self.assertEqual(response.status_code, 400) - self.assertFalse(profile.assignments.exists()) - - def test_profile_update_rejects_capabilities_that_break_active_scope(self) -> None: - profile = AccessProfile.objects.create( - organization=self.organization, name="Department reader" - ) - AccessProfileCapability.objects.create( - access_profile=profile, capability_code="customers.view" - ) - assigned = self.client.post( - f"/api/v1/employees/{self.employee.user_id}/access-assignments/", - { - "profileId": profile.id, - "scopeType": "DEPARTMENT", - "departmentId": self.sales.id, - }, - format="json", - ) - self.assertEqual(assigned.status_code, 201) - - response = self.client.patch( - f"/api/v1/access-profiles/{profile.id}/", - {"capabilities": ["company.view"]}, - format="json", - ) - - self.assertEqual(response.status_code, 409) - self.assertEqual( - list(profile.capability_links.values_list("capability_code", flat=True)), - ["customers.view"], - ) - - def test_system_profile_is_read_only(self) -> None: - profile = AccessProfile.objects.create( - organization=self.organization, - name="System profile", - is_system=True, - ) - - response = self.client.patch( - f"/api/v1/access-profiles/{profile.id}/", - {"isActive": False}, - format="json", - ) - - self.assertEqual(response.status_code, 409) - profile.refresh_from_db() - self.assertTrue(profile.is_active) - - def test_registry_rejects_unknown_and_protected_codes(self) -> None: - for code in ("invented.permission", "ownership.transfer"): - response = self.client.post( - "/api/v1/access-profiles/", - {"name": f"Invalid {code}", "capabilities": [code]}, - format="json", - ) - self.assertEqual(response.status_code, 400) - self.assertFalse(AccessProfile.objects.filter(name__startswith="Invalid").exists()) - - def test_admin_cannot_change_other_admin_access(self) -> None: - profile = AccessProfile.objects.create( - organization=self.organization, name="Empty profile" - ) - self.client.force_authenticate(self.admin.user) - response = self.client.post( - f"/api/v1/employees/{self.other_admin.user_id}/access-assignments/", - {"profileId": profile.id, "scopeType": "ORGANIZATION"}, - format="json", - ) - self.assertEqual(response.status_code, 403) - - def test_owner_cannot_assign_profile_to_admin(self) -> None: - profile = AccessProfile.objects.create( - organization=self.organization, name="Employee profile" - ) - - response = self.client.post( - f"/api/v1/employees/{self.admin.user_id}/access-assignments/", - {"profileId": profile.id, "scopeType": "ORGANIZATION"}, - format="json", - ) - - self.assertEqual(response.status_code, 400) - self.assertFalse(profile.assignments.exists()) - - def test_profile_name_is_required(self) -> None: - response = self.client.post( - "/api/v1/access-profiles/", - {"name": " ", "capabilities": []}, - format="json", - ) - - self.assertEqual(response.status_code, 400) - - def test_employee_cannot_manage_profiles(self) -> None: - self.client.force_authenticate(self.employee.user) - response = self.client.get("/api/v1/access-profiles/") - self.assertEqual(response.status_code, 403) - - def test_employee_create_accepts_scoped_assignments_atomically(self) -> None: - profile = AccessProfile.objects.create( - organization=self.organization, name="New employee profile" - ) - response = self.client.post( - "/api/v1/employees/operators/", - { - "email": "new@access.test", - "fullName": "New Employee", - "positionTitle": "Specialist", - "role": "EMPLOYEE", - "department": "sales", - "accessAssignments": [ - { - "profileId": profile.id, - "scopeType": "DEPARTMENT", - "departmentId": self.sales.id, - } - ], - }, - format="json", - ) - self.assertEqual(response.status_code, 201) - created = OrganizationMembership.objects.get(user__email="new@access.test") - self.assertEqual(created.access_assignments.get().department, self.sales) - - def test_employee_create_without_password_queues_first_access_email(self) -> None: - response = self.client.post( - "/api/v1/employees/operators/", - { - "email": "invited@access.test", - "fullName": "Invited Employee", - "positionTitle": "Specialist", - "role": "EMPLOYEE", - "department": "sales", - }, - format="json", - ) - - self.assertEqual(response.status_code, 201) - created = HumanUser.objects.get(email="invited@access.test") - self.assertFalse(created.has_usable_password()) - self.assertTrue(created.must_change_password) - self.assertTrue( - OutboxEvent.objects.filter( - aggregate_id=str(created.id), - event_type="identity.initial_access_requested", - ).exists() - ) diff --git a/apps/backend/hub_platform/identity/test_authorization.py b/apps/backend/hub_platform/identity/test_authorization.py index 38d51d8..b873293 100644 --- a/apps/backend/hub_platform/identity/test_authorization.py +++ b/apps/backend/hub_platform/identity/test_authorization.py @@ -1,13 +1,7 @@ -from django.core.exceptions import ValidationError from django.test import TestCase -from django.utils import timezone -from hub_platform.identity.capabilities import ScopeType +from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - Department, - EmployeeAccessAssignment, EmployeeRole, HumanUser, Organization, @@ -15,167 +9,99 @@ from hub_platform.identity.models import ( ) from hub_platform.identity.policy import ( ResourceScope, - accessible_department_ids, authorize, + conversation_visibility, get_effective_access, + has_capability_any_scope, ) -class CapabilityPolicyTests(TestCase): +class RolePolicyTests(TestCase): + """Ролевая авторизация SPEC-HUB-0031 §3 + видимость по группам ADR-HUB-0043.""" + def setUp(self) -> None: self.organization = Organization.objects.create(name="Example", slug="example") self.other_organization = Organization.objects.create(name="Other", slug="other") - self.sales = Department.objects.create( - organization=self.organization, code="sales", name="Sales" + self.operators = EmployeeGroup.objects.create( + organization=self.organization, name="Операторы" ) - self.support = Department.objects.create( - organization=self.organization, code="support", name="Support" + self.support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" ) self.owner = self._employee("owner@example.test", EmployeeRole.OWNER) self.admin = self._employee("admin@example.test", EmployeeRole.ADMIN) - self.employee = self._employee( - "employee@example.test", EmployeeRole.EMPLOYEE, self.sales + self.employee = self._employee("employee@example.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.operators, employee=self.employee ) - def _employee( - self, email: str, role: str, department: Department | None = None - ) -> OrganizationMembership: + def _employee(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", - primary_department=department, ) - def _profile(self, name: str, *codes: str) -> AccessProfile: - profile = AccessProfile.objects.create(organization=self.organization, name=name) - for code in codes: - AccessProfileCapability.objects.create( - access_profile=profile, capability_code=code - ) - return profile + def test_owner_and_admin_are_identical_except_ownership_transfer(self) -> None: + scope = ResourceScope(self.organization.id) + for capability in ( + "employees.manage", + "employees.manage_privileged", + "integrations.manage", + "channels.manage", + "ai.manage", + "settings.manage", + "groups.manage", + ): + self.assertTrue(authorize(self.owner, capability, scope), capability) + self.assertTrue(authorize(self.admin, capability, scope), capability) + self.assertTrue(authorize(self.owner, "ownership.transfer", scope)) + self.assertFalse(authorize(self.admin, "ownership.transfer", scope)) - def _assign( - self, - profile: AccessProfile, - *, - department: Department | None = None, - ) -> EmployeeAccessAssignment: - return EmployeeAccessAssignment.objects.create( - employee=self.employee, - access_profile=profile, - scope_type=ScopeType.DEPARTMENT if department else ScopeType.ORGANIZATION, - department=department, - assigned_by=self.owner, - ) + def test_employee_is_limited_to_chat_capabilities(self) -> None: + scope = ResourceScope(self.organization.id) + self.assertTrue(authorize(self.employee, "conversations.view", scope)) + self.assertTrue(authorize(self.employee, "conversations.operate", scope)) + self.assertTrue(authorize(self.employee, "customers.view", scope)) + self.assertFalse(authorize(self.employee, "employees.view", scope)) + self.assertFalse(authorize(self.employee, "ai.view", scope)) + self.assertFalse(authorize(self.employee, "channels.view", scope)) + self.assertFalse(authorize(self.employee, "settings.manage", scope)) - def test_owner_and_admin_role_policy(self) -> None: - organization_scope = ResourceScope(self.organization.id) - self.assertTrue(authorize(self.owner, "ownership.transfer", organization_scope)) - self.assertFalse(authorize(self.admin, "ownership.transfer", organization_scope)) - self.assertTrue(authorize(self.admin, "integrations.manage", organization_scope)) - - def test_department_assignment_does_not_cross_department(self) -> None: - self._assign( - self._profile("Sales operator", "conversations.view"), department=self.sales - ) - self.assertTrue( - authorize( - self.employee, - "conversations.view", - ResourceScope(self.organization.id, self.sales.id), - ) - ) + def test_cross_organization_scope_is_denied(self) -> None: self.assertFalse( authorize( - self.employee, + self.owner, "conversations.view", - ResourceScope(self.organization.id, self.support.id), + ResourceScope(self.other_organization.id), ) ) + + def test_blocked_member_loses_access(self) -> None: + self.employee.block() self.assertFalse( - authorize( - self.employee, - "conversations.view", - ResourceScope(self.other_organization.id, self.sales.id), - ) + has_capability_any_scope(self.employee, "conversations.view") ) - def test_primary_department_never_grants_access(self) -> None: - self.assertFalse( - authorize( - self.employee, - "conversations.view", - ResourceScope(self.organization.id, self.sales.id), - ) - ) + def test_conversation_visibility_scopes(self) -> None: + self.assertIsNone(conversation_visibility(self.owner)) + self.assertIsNone(conversation_visibility(self.admin)) + scope = conversation_visibility(self.employee) + self.assertEqual(scope["group_ids"], {self.operators.id}) + self.assertEqual(scope["user_id"], self.employee.user_id) - def test_organization_assignment_covers_departments(self) -> None: - self._assign(self._profile("Company reader", "conversations.view")) - self.assertTrue( - authorize( - self.employee, - "conversations.view", - ResourceScope(self.organization.id, self.support.id), - ) - ) - self.assertIsNone(accessible_department_ids(self.employee, "conversations.view")) - - def test_multiple_assignments_are_unioned_and_exposed(self) -> None: - self._assign( - self._profile("Sales reader", "customers.view", "conversations.view"), - department=self.sales, - ) - self._assign( - self._profile("Support reader", "support.view", "conversations.view"), - department=self.support, - ) - access = get_effective_access(self.employee) + def test_effective_access_payload(self) -> None: + owner_access = get_effective_access(self.owner) + self.assertIn("ownership.transfer", owner_access["capabilities"]) + admin_access = get_effective_access(self.admin) + self.assertNotIn("ownership.transfer", admin_access["capabilities"]) + self.assertIn("employees.manage_privileged", admin_access["capabilities"]) + employee_access = get_effective_access(self.employee) self.assertEqual( - access["capabilities"], - ["conversations.view", "customers.view", "support.view"], + employee_access["groups"], + [{"id": self.operators.id, "name": "Операторы"}], ) - self.assertEqual( - accessible_department_ids(self.employee, "conversations.view"), - {self.sales.id, self.support.id}, - ) - self.assertEqual(len(access["accessScopes"]), 2) - - def test_revoked_or_disabled_assignment_stops_access_immediately(self) -> None: - profile = self._profile("Reader", "products.view") - assignment = self._assign(profile, department=self.sales) - scope = ResourceScope(self.organization.id, self.sales.id) - self.assertTrue(authorize(self.employee, "products.view", scope)) - assignment.revoked_at = timezone.now() - assignment.save(update_fields=["revoked_at"]) - self.assertFalse(authorize(self.employee, "products.view", scope)) - - second = self._assign(profile, department=self.sales) - profile.is_active = False - profile.save() - self.assertFalse(authorize(self.employee, "products.view", scope)) - self.assertIsNotNone(second.id) - - def test_unknown_and_protected_capabilities_are_rejected(self) -> None: - profile = AccessProfile.objects.create(organization=self.organization, name="Invalid") - with self.assertRaises(ValidationError): - AccessProfileCapability.objects.create( - access_profile=profile, capability_code="invented.permission" - ) - with self.assertRaises(ValidationError): - AccessProfileCapability.objects.create( - access_profile=profile, capability_code="ownership.transfer" - ) - - def test_assignment_organization_and_scope_invariants(self) -> None: - profile = self._profile("Reader", "products.view") - with self.assertRaises(ValidationError): - EmployeeAccessAssignment.objects.create( - employee=self.employee, - access_profile=profile, - scope_type=ScopeType.DEPARTMENT, - department=None, - assigned_by=self.owner, - ) + self.assertIn("conversations.view", employee_access["capabilities"]) + self.assertNotIn("ai.view", employee_access["capabilities"]) diff --git a/apps/backend/hub_platform/identity/test_channel_capabilities.py b/apps/backend/hub_platform/identity/test_channel_capabilities.py deleted file mode 100644 index bffc6f7..0000000 --- a/apps/backend/hub_platform/identity/test_channel_capabilities.py +++ /dev/null @@ -1,262 +0,0 @@ -"""SPEC-HUB-0027 §5 — capability каналов и миграция прав по существующим ai.*.""" - -import importlib - -from django.db import connection -from django.db.migrations.executor import MigrationExecutor -from django.test import TestCase, TransactionTestCase - -from hub_platform.identity.access_services import allowed_profile_scopes -from hub_platform.identity.capabilities import ( - CAPABILITY_REGISTRY, - PROTECTED_CAPABILITIES, - ScopeType, -) -from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - Department, - EmployeeAccessAssignment, - EmployeeRole, - HumanUser, - Organization, - OrganizationMembership, -) -from hub_platform.identity.policy import ResourceScope, authorize - -CHANNELS_VIEW = "channels.view" -CHANNELS_MANAGE = "channels.manage" - - -class ChannelCapabilityRegistryTests(TestCase): - """§5.1 — обе capability назначаемы и допускают оба scope.""" - - def test_registry_declares_both_scopes_and_is_assignable(self) -> None: - for code in (CHANNELS_VIEW, CHANNELS_MANAGE): - with self.subTest(code=code): - spec = CAPABILITY_REGISTRY[code] - self.assertEqual( - spec.allowed_scopes, - frozenset({ScopeType.ORGANIZATION, ScopeType.DEPARTMENT}), - ) - self.assertTrue(spec.assignable) - self.assertNotIn(code, PROTECTED_CAPABILITIES) - - def test_channel_capabilities_do_not_narrow_profile_scopes(self) -> None: - # §5.3: набор допустимых scope профиля — пересечение по его capability. - # Если бы channels.* были organization-only, department-назначения - # существующих ai-профилей стали бы невалидными при выкате. - self.assertEqual( - allowed_profile_scopes(["ai.view", "ai.manage"]), - allowed_profile_scopes( - ["ai.view", "ai.manage", CHANNELS_VIEW, CHANNELS_MANAGE] - ), - ) - - -class ChannelCapabilityScopeTests(TestCase): - """§5.2 — фильтрация по scope канала, включая канал без отдела.""" - - def setUp(self) -> None: - self.organization = Organization.objects.create(name="Example", slug="example") - self.sales = Department.objects.create( - organization=self.organization, code="sales", name="Sales" - ) - self.support = Department.objects.create( - organization=self.organization, code="support", name="Support" - ) - self.owner = self._employee("owner@example.test", EmployeeRole.OWNER) - self.employee = self._employee("employee@example.test", EmployeeRole.EMPLOYEE) - - def _employee(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 _assign(self, *codes: str, department: Department | None = None) -> None: - profile = AccessProfile.objects.create( - organization=self.organization, name=f"Profile {'-'.join(codes)}" - ) - for code in codes: - AccessProfileCapability.objects.create( - access_profile=profile, capability_code=code - ) - EmployeeAccessAssignment.objects.create( - employee=self.employee, - access_profile=profile, - scope_type=ScopeType.DEPARTMENT if department else ScopeType.ORGANIZATION, - department=department, - assigned_by=self.owner, - ) - - def test_department_scope_covers_only_its_own_department(self) -> None: - self._assign(CHANNELS_VIEW, department=self.sales) - - self.assertTrue( - authorize( - self.employee, - CHANNELS_VIEW, - ResourceScope(self.organization.id, self.sales.id), - ) - ) - self.assertFalse( - authorize( - self.employee, - CHANNELS_VIEW, - ResourceScope(self.organization.id, self.support.id), - ) - ) - - def test_channel_without_department_needs_organization_scope(self) -> None: - # §5.2: канал без отдела виден только organization-scoped channels.view — - # department-scoped сотрудник не получает его ни списком, ни по ID. - self._assign(CHANNELS_VIEW, department=self.sales) - self.assertFalse( - authorize( - self.employee, CHANNELS_VIEW, ResourceScope(self.organization.id, None) - ) - ) - - def test_organization_scope_covers_channels_with_and_without_department(self) -> None: - self._assign(CHANNELS_VIEW, CHANNELS_MANAGE) - - for department_id in (self.sales.id, self.support.id, None): - with self.subTest(department_id=department_id): - self.assertTrue( - authorize( - self.employee, - CHANNELS_MANAGE, - ResourceScope(self.organization.id, department_id), - ) - ) - - def test_ai_capability_no_longer_authorizes_channels(self) -> None: - # ADR-HUB-0037 §9: право настраивать AI больше не даёт прав на канал. - self._assign("ai.view", "ai.manage") - self.assertFalse( - authorize( - self.employee, CHANNELS_VIEW, ResourceScope(self.organization.id, None) - ) - ) - - -class ChannelCapabilityMigrationTests(TransactionTestCase): - """§5.3 — выдача channels.* по существующим ai.* без потери доступа.""" - - migrate_from = [("identity", "0015_organization_status")] - migrate_to = [("identity", "0016_channel_capabilities")] - - def setUp(self) -> None: - super().setUp() - executor = MigrationExecutor(connection) - executor.migrate(self.migrate_from) - old_apps = executor.loader.project_state(self.migrate_from).apps - - Organization = old_apps.get_model("identity", "Organization") - AccessProfile = old_apps.get_model("identity", "AccessProfile") - AccessProfileCapability = old_apps.get_model("identity", "AccessProfileCapability") - - organization = Organization.objects.create(name="Edevs", slug="edevs") - other = Organization.objects.create(name="Other", slug="other") - - def profile(org, name: str, *codes: str) -> int: - row = AccessProfile.objects.create(organization=org, name=name) - for code in codes: - AccessProfileCapability.objects.create( - access_profile=row, organization=org, capability_code=code - ) - return row.pk - - self.editor_id = profile(organization, "AI editor", "ai.view", "ai.manage") - self.reader_id = profile(organization, "AI reader", "ai.view") - self.operator_id = profile(organization, "Operator", "conversations.view") - self.other_editor_id = profile(other, "AI editor", "ai.view", "ai.manage") - self.organization_id = organization.pk - self.other_organization_id = other.pk - - def tearDown(self) -> None: - executor = MigrationExecutor(connection) - executor.migrate(executor.loader.graph.leaf_nodes()) - super().tearDown() - - def _codes(self, apps, profile_id: int) -> set[str]: - AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability") - return set( - AccessProfileCapability.objects.filter( - access_profile_id=profile_id - ).values_list("capability_code", flat=True) - ) - - def test_forward_grants_channel_capabilities_by_ai_capabilities(self) -> None: - executor = MigrationExecutor(connection) - executor.migrate(self.migrate_to) - new_apps = executor.loader.project_state(self.migrate_to).apps - - self.assertEqual( - self._codes(new_apps, self.editor_id), - {"ai.view", "ai.manage", CHANNELS_VIEW, CHANNELS_MANAGE}, - ) - # ai.view без ai.manage даёт только просмотр: доступ не расширяется. - self.assertEqual( - self._codes(new_apps, self.reader_id), {"ai.view", CHANNELS_VIEW} - ) - self.assertEqual(self._codes(new_apps, self.operator_id), {"conversations.view"}) - - def test_forward_keeps_capability_rows_in_their_own_organization(self) -> None: - executor = MigrationExecutor(connection) - executor.migrate(self.migrate_to) - new_apps = executor.loader.project_state(self.migrate_to).apps - AccessProfileCapability = new_apps.get_model("identity", "AccessProfileCapability") - - # organization_id обязателен и обязан совпадать с владельцем профиля: - # триггер custocrm.enforce_tenant_fk отвергает расхождение. - for profile_id, organization_id in ( - (self.editor_id, self.organization_id), - (self.other_editor_id, self.other_organization_id), - ): - with self.subTest(profile_id=profile_id): - rows = AccessProfileCapability.objects.filter( - access_profile_id=profile_id, - capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE), - ) - self.assertEqual(rows.count(), 2) - self.assertEqual( - set(rows.values_list("organization_id", flat=True)), - {organization_id}, - ) - - def test_grant_is_idempotent(self) -> None: - executor = MigrationExecutor(connection) - executor.migrate(self.migrate_to) - new_apps = executor.loader.project_state(self.migrate_to).apps - AccessProfileCapability = new_apps.get_model("identity", "AccessProfileCapability") - before = AccessProfileCapability.objects.count() - - migration = importlib.import_module( - "hub_platform.identity.migrations.0016_channel_capabilities" - ) - migration.grant_channel_capabilities(new_apps, None) - - self.assertEqual(AccessProfileCapability.objects.count(), before) - - def test_reverse_removes_granted_rows_before_restoring_constraint(self) -> None: - executor = MigrationExecutor(connection) - executor.migrate(self.migrate_to) - - # Откат обязан пройти целиком: если бы строки channels.* пережили - # RunPython, восстановление прежнего check-constraint упало бы. - executor = MigrationExecutor(connection) - executor.migrate(self.migrate_from) - old_apps = executor.loader.project_state(self.migrate_from).apps - - self.assertEqual(self._codes(old_apps, self.editor_id), {"ai.view", "ai.manage"}) - AccessProfileCapability = old_apps.get_model("identity", "AccessProfileCapability") - self.assertFalse( - AccessProfileCapability.objects.filter( - capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE) - ).exists() - ) diff --git a/apps/backend/hub_platform/identity/test_membership_identity.py b/apps/backend/hub_platform/identity/test_membership_identity.py index dd4cb46..3af5fdd 100644 --- a/apps/backend/hub_platform/identity/test_membership_identity.py +++ b/apps/backend/hub_platform/identity/test_membership_identity.py @@ -11,7 +11,6 @@ from hub_platform.identity.invitation_service import ( pending_invitation_for_token, ) from hub_platform.identity.models import ( - EmployeeAccessAssignment, EmployeeRole, HumanUser, Organization, @@ -118,12 +117,6 @@ class MembershipIdentityTests(TestCase): ) ) - def test_access_assignment_targets_membership_model(self) -> None: - self.assertIs( - EmployeeAccessAssignment._meta.get_field("employee").remote_field.model, - OrganizationMembership, - ) - def test_invitation_stores_only_hash_and_can_precede_human_user(self) -> None: issued = issue_invitation( organization=self.first_organization, diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/hub_platform/identity/tests.py index 70d92da..e675a1e 100644 --- a/apps/backend/hub_platform/identity/tests.py +++ b/apps/backend/hub_platform/identity/tests.py @@ -15,7 +15,7 @@ from hub_platform.identity.bootstrap import bootstrap_edevs_owner from hub_platform.identity.auth.totp_utils import _totp_code from hub_platform.identity.models import ( AuditEvent, - Department, + EmployeeGroup, EmployeeRole, HumanUser, Organization, @@ -37,11 +37,12 @@ class BootstrapOwnerTests(TestCase): self.assertTrue(result.created_owner) self.assertEqual(Organization.objects.get().slug, "edevs") - # Seed создаёт отделы продаж и поддержки (SPEC-HUB-0010 §4.1). + # Seed создаёт стартовые группы (ADR-HUB-0043). self.assertEqual( - set(Department.objects.values_list("code", flat=True)), {"sales", "support"} + set(EmployeeGroup.objects.values_list("name", flat=True)), + {"Операторы", "Поддержка"}, ) - self.assertEqual(result.support_department.code, "support") + self.assertEqual(result.support_group.name, "Поддержка") self.assertEqual(set(Product.objects.values_list("code", flat=True)), {"firepage", "foxray"}) self.assertEqual(result.owner.memberships.get().role, EmployeeRole.OWNER) # TOTP выключен по умолчанию (намеренно, локальная разработка). @@ -52,14 +53,9 @@ class BootstrapOwnerTests(TestCase): self.assertEqual(operator.full_name, "Анна Котова") self.assertEqual(operator.memberships.get().role, EmployeeRole.EMPLOYEE) self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 02") - assignment = operator.memberships.get().access_assignments.get(revoked_at__isnull=True) - self.assertEqual(assignment.scope_type, "DEPARTMENT") - self.assertEqual(assignment.department.code, "sales") - self.assertIn( - "conversations.view", - assignment.access_profile.capability_links.values_list( - "capability_code", flat=True - ), + self.assertEqual( + [link.group.name for link in operator.memberships.get().group_links.all()], + ["Операторы"], ) self.assertTrue(AuditEvent.objects.filter(action="identity.owner_bootstrapped").exists()) @@ -88,7 +84,6 @@ class PermissionTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.sales = Department.objects.get(code="sales") def test_owner_can_access_global_settings_and_sales_workspace(self) -> None: owner = HumanUser.objects.get(email="owner@edevs.tech") @@ -101,7 +96,7 @@ class PermissionTests(TestCase): authorize( membership, "conversations.view", - ResourceScope(self.organization.id, self.sales.id), + ResourceScope(self.organization.id), ) ) @@ -115,7 +110,7 @@ class PermissionTests(TestCase): authorize( membership, "conversations.view", - ResourceScope(self.organization.id, self.sales.id), + ResourceScope(self.organization.id), ) ) @@ -139,7 +134,7 @@ class AuthEndpointTests(TestCase): self.assertNotIn("role", payload["user"]) self.assertEqual(len(payload["user"]["memberships"]), 1) membership = payload["user"]["memberships"][0] - self.assertIn("accessScopes", membership) + self.assertIn("groups", membership) self.assertIn("employees.manage_privileged", membership["capabilities"]) self.assertEqual(membership["organizationName"], "Edevs") self.assertEqual(membership["role"], EmployeeRole.OWNER) @@ -420,7 +415,6 @@ class EmployeeEndpointTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.sales = Department.objects.get(code="sales") self.client = APIClient() self.client.login(username="owner@edevs.tech", password="temporary-password") @@ -447,7 +441,6 @@ class EmployeeEndpointTests(TestCase): user=operator, organization=self.organization, role=EmployeeRole.EMPLOYEE, - primary_department=self.sales, ) self.client.logout() self.client.login(username="operator@edevs.tech", password="operator-password") @@ -476,7 +469,6 @@ class EmployeeEndpointTests(TestCase): user=operator, organization=self.organization, role=EmployeeRole.EMPLOYEE, - primary_department=self.sales, ) response = self.client.post(f"/api/v1/employees/{operator.id}/block/") @@ -499,7 +491,6 @@ class EmployeeEndpointTests(TestCase): "phone": "+7 916 245 14 03", "positionTitle": "Оператор продаж", "role": EmployeeRole.EMPLOYEE, - "department": "sales", "totpEnabled": True, } ), @@ -541,19 +532,18 @@ class CompanyEndpointTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.sales = Department.objects.get(code="sales") self.client = APIClient() self.client.login(username="owner@edevs.tech", password="temporary-password") - def test_owner_reads_departments_and_products(self) -> None: - departments_response = self.client.get("/api/v1/company/departments/") + def test_owner_reads_groups_and_products(self) -> None: + groups_response = self.client.get("/api/v1/company/groups/") products_response = self.client.get("/api/v1/company/products/") - self.assertEqual(departments_response.status_code, 200) + self.assertEqual(groups_response.status_code, 200) self.assertEqual(products_response.status_code, 200) self.assertEqual( - set(d["code"] for d in departments_response.json()["items"]), - {"sales", "support"}, + set(group["name"] for group in groups_response.json()["items"]), + {"Операторы", "Поддержка"}, ) self.assertEqual( set(product["code"] for product in products_response.json()["items"]), @@ -583,7 +573,6 @@ class CompanyEndpointTests(TestCase): user=operator, organization=self.organization, role=EmployeeRole.EMPLOYEE, - primary_department=self.sales, ) self.client.logout() self.client.login(username="operator@edevs.tech", password="operator-password") @@ -652,18 +641,19 @@ class EmployeeModelInvariantTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.sales = Department.objects.get(code="sales") - def test_bootstrap_owner_is_company_level_with_title(self) -> None: + def test_bootstrap_owner_has_owner_role_and_title(self) -> None: owner = HumanUser.objects.get(email="owner@edevs.tech") self.assertEqual(owner.memberships.get().role, EmployeeRole.OWNER) - self.assertIsNone(owner.memberships.get().primary_department) self.assertTrue(owner.memberships.get().position_title) - def test_bootstrapped_operator_is_employee_in_sales(self) -> None: + def test_bootstrapped_operator_is_employee_in_operators_group(self) -> None: operator = HumanUser.objects.get(email="a.kotova@edevs.tech") self.assertEqual(operator.memberships.get().role, EmployeeRole.EMPLOYEE) - self.assertEqual(operator.memberships.get().primary_department, self.sales) + self.assertEqual( + [link.group.name for link in operator.memberships.get().group_links.all()], + ["Операторы"], + ) self.assertTrue(operator.memberships.get().position_title) def test_second_owner_is_rejected(self) -> None: @@ -677,22 +667,6 @@ class EmployeeModelInvariantTests(TestCase): organization=self.organization, role=EmployeeRole.OWNER, position_title="Второй владелец", - primary_department=None, - ) - - def test_owner_with_primary_department_is_rejected(self) -> None: - from django.db import IntegrityError, transaction - - user = HumanUser.objects.create_user(email="owner3@edevs.tech", password="temporary-password") - other_org = Organization.objects.create(name="Other", slug="other") - with self.assertRaises(IntegrityError): - with transaction.atomic(): - OrganizationMembership.objects.create( - user=user, - organization=other_org, - role=EmployeeRole.OWNER, - position_title="Владелец", - primary_department=self.sales, ) def test_create_employee_requires_position_title(self) -> None: @@ -719,16 +693,14 @@ class EmployeeGovernanceTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.sales = Department.objects.get(code="sales") - def _make(self, email: str, role: str, department: Department | None = None) -> HumanUser: + def _make(self, email: str, role: str) -> HumanUser: user = HumanUser.objects.create_user(email=email, password="member-password-123") OrganizationMembership.objects.create( user=user, organization=self.organization, role=role, position_title="Позиция", - primary_department=None if role in (EmployeeRole.OWNER, EmployeeRole.ADMIN) else (department or self.sales), ) return user @@ -753,22 +725,22 @@ class EmployeeGovernanceTests(TestCase): # --- создание и назначение ролей --- - def test_owner_creates_admin_at_company_level(self) -> None: + def test_owner_creates_admin(self) -> None: client = self._client("owner@edevs.tech", "temporary-password") response = self._create(client, "admin@edevs.tech", EmployeeRole.ADMIN) self.assertEqual(response.status_code, 201) profile = HumanUser.objects.get(email="admin@edevs.tech").memberships.get() self.assertEqual(profile.role, EmployeeRole.ADMIN) - self.assertIsNone(profile.primary_department) self.assertTrue(AuditEvent.objects.filter(action="identity.employee_created").exists()) - def test_admin_cannot_create_admin(self) -> None: + def test_admin_creates_admin(self) -> None: + # SPEC-HUB-0031 §3: ADMIN идентичен OWNER и может создавать админов. self._make("admin@edevs.tech", EmployeeRole.ADMIN) response = self._create(self._client("admin@edevs.tech"), "admin2@edevs.tech", EmployeeRole.ADMIN) - self.assertEqual(response.status_code, 403) - self.assertFalse(HumanUser.objects.filter(email="admin2@edevs.tech").exists()) - self.assertTrue( - AuditEvent.objects.filter(action="identity.employee_privileged_action_denied").exists() + self.assertEqual(response.status_code, 201) + self.assertEqual( + HumanUser.objects.get(email="admin2@edevs.tech").memberships.get().role, + EmployeeRole.ADMIN, ) def test_admin_creates_employee(self) -> None: @@ -797,13 +769,13 @@ class EmployeeGovernanceTests(TestCase): self.assertTrue(emp.is_active) self.assertTrue(emp.memberships.get().is_blocked) - def test_admin_cannot_block_another_admin(self) -> None: + def test_admin_blocks_another_admin(self) -> None: + # SPEC-HUB-0031 §3: админы управляют друг другом; защищён только владелец. other = self._make("admin2@edevs.tech", EmployeeRole.ADMIN) self._make("admin@edevs.tech", EmployeeRole.ADMIN) response = self._client("admin@edevs.tech").post(f"/api/v1/employees/{other.id}/block/") - self.assertEqual(response.status_code, 403) - other.refresh_from_db() - self.assertTrue(other.is_active) + self.assertEqual(response.status_code, 200) + self.assertTrue(other.memberships.get().is_blocked) def test_admin_cannot_block_owner(self) -> None: owner = HumanUser.objects.get(email="owner@edevs.tech") @@ -813,17 +785,19 @@ class EmployeeGovernanceTests(TestCase): owner.refresh_from_db() self.assertTrue(owner.is_active) - def test_admin_cannot_update_another_admin(self) -> None: + def test_admin_updates_another_admin(self) -> None: other = self._make("admin2@edevs.tech", EmployeeRole.ADMIN) self._make("admin@edevs.tech", EmployeeRole.ADMIN) response = self._client("admin@edevs.tech").post( f"/api/v1/employees/{other.id}/update/", - data=json.dumps({"fullName": "Hacked", "email": "admin2@edevs.tech", "positionTitle": "X"}), + data=json.dumps({"fullName": "Renamed", "email": "admin2@edevs.tech", "positionTitle": "X"}), content_type="application/json", ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) + other.refresh_from_db() + self.assertEqual(other.full_name, "Renamed") - def test_admin_cannot_change_employee_role(self) -> None: + def test_admin_promotes_employee_to_admin(self) -> None: emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE) self._make("admin@edevs.tech", EmployeeRole.ADMIN) response = self._client("admin@edevs.tech").post( @@ -833,9 +807,9 @@ class EmployeeGovernanceTests(TestCase): ), content_type="application/json", ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) emp.memberships.get().refresh_from_db() - self.assertEqual(emp.memberships.get().role, EmployeeRole.EMPLOYEE) + self.assertEqual(emp.memberships.get().role, EmployeeRole.ADMIN) def test_owner_promotes_employee_to_admin(self) -> None: emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE) @@ -883,7 +857,6 @@ class EmployeeGovernanceTests(TestCase): target.memberships.get().refresh_from_db() owner.memberships.get().refresh_from_db() self.assertEqual(target.memberships.get().role, EmployeeRole.OWNER) - self.assertIsNone(target.memberships.get().primary_department) self.assertEqual(owner.memberships.get().role, EmployeeRole.ADMIN) self.assertEqual( OrganizationMembership.objects.filter(organization=self.organization, role=EmployeeRole.OWNER).count(), @@ -929,7 +902,8 @@ class EmployeeGovernanceTests(TestCase): self._make("admin@edevs.tech", EmployeeRole.ADMIN) items = self._client("admin@edevs.tech").get("/api/v1/employees/").json()["items"] by_email = {item["email"]: item for item in items} - # ADMIN может управлять EMPLOYEE, но не OWNER и не менять роли. + # ADMIN идентичен OWNER: управляет всеми, кроме блокировки/удаления владельца. self.assertTrue(by_email["emp@edevs.tech"]["permissions"]["canBlock"]) - self.assertFalse(by_email["emp@edevs.tech"]["permissions"]["canChangeRole"]) + self.assertTrue(by_email["emp@edevs.tech"]["permissions"]["canChangeRole"]) self.assertFalse(by_email["owner@edevs.tech"]["permissions"]["canBlock"]) + self.assertTrue(by_email["owner@edevs.tech"]["permissions"]["canUpdateProfile"]) diff --git a/apps/backend/hub_platform/notifications/migrations/0008_remove_notification_department.py b/apps/backend/hub_platform/notifications/migrations/0008_remove_notification_department.py new file mode 100644 index 0000000..de8d5df --- /dev/null +++ b/apps/backend/hub_platform/notifications/migrations/0008_remove_notification_department.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + # Дроп после tenancy-гардов (RLS/триггеры ссылаются на эти таблицы). + ('tenancy', '0017_drop_commerce'), + ('notifications', '0007_drop_payment_types'), + ] + + operations = [ + migrations.RemoveField( + model_name='notification', + name='department', + ), + ] diff --git a/apps/backend/hub_platform/notifications/models.py b/apps/backend/hub_platform/notifications/models.py index f431c0b..1f1164b 100644 --- a/apps/backend/hub_platform/notifications/models.py +++ b/apps/backend/hub_platform/notifications/models.py @@ -32,13 +32,6 @@ class NotificationAudience(models.TextChoices): class Notification(models.Model): organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="notifications") - department = models.ForeignKey( - "identity.Department", - on_delete=models.PROTECT, - related_name="notifications", - null=True, - blank=True, - ) type = models.CharField(max_length=32, choices=NotificationType.choices) level = models.CharField(max_length=16, choices=NotificationLevel.choices, default=NotificationLevel.INFO) title = models.CharField(max_length=255) diff --git a/apps/backend/hub_platform/notifications/selectors.py b/apps/backend/hub_platform/notifications/selectors.py index 3f1138a..7edec53 100644 --- a/apps/backend/hub_platform/notifications/selectors.py +++ b/apps/backend/hub_platform/notifications/selectors.py @@ -1,6 +1,6 @@ from django.db.models import Q, QuerySet -from hub_platform.identity.policy import ResourceScope, accessible_department_ids, authorize +from hub_platform.identity.policy import ResourceScope, authorize from hub_platform.notifications.models import Notification, NotificationAudience from hub_platform.tenancy.context import TenantContext @@ -18,15 +18,9 @@ def visible_for(context: TenantContext) -> QuerySet[Notification]: audiences.append(NotificationAudience.OPERATORS) if authorize(profile, "employees.manage_privileged", organization_scope): audiences.append(NotificationAudience.OWNER) - department_ids = accessible_department_ids(profile, "conversations.view") - operator_scope = Q(audience=NotificationAudience.OPERATORS) - if department_ids is not None: - operator_scope &= Q(department_id__in=department_ids) - audience_scope = Q(audience__in=[a for a in audiences if a != NotificationAudience.OPERATORS]) - if NotificationAudience.OPERATORS in audiences or department_ids: - audience_scope |= operator_scope return Notification.objects.filter(organization_id=profile.organization_id).filter( - audience_scope | Q(audience=NotificationAudience.USER, recipient_user=user) + Q(audience__in=audiences) + | Q(audience=NotificationAudience.USER, recipient_user=user) ) diff --git a/apps/backend/hub_platform/notifications/services.py b/apps/backend/hub_platform/notifications/services.py index a4dd965..437bcd1 100644 --- a/apps/backend/hub_platform/notifications/services.py +++ b/apps/backend/hub_platform/notifications/services.py @@ -28,7 +28,6 @@ TYPE_META: dict[str, dict] = { def notify( *, context, - department=None, type: str, audience: str, title: str, @@ -47,11 +46,8 @@ def notify( return None meta = TYPE_META.get(type, {}) target_route = meta.get("route", "") - if type in {NotificationType.DIALOG_WAITING, NotificationType.DIALOG_NEW_MESSAGE}: - target_route = "supportDialogs" if getattr(department, "code", None) == "support" else "salesDialogs" notification = Notification.objects.create( organization=organization, - department=department, type=type, audience=audience, title=title, diff --git a/apps/backend/hub_platform/notifications/tests.py b/apps/backend/hub_platform/notifications/tests.py index f6472d3..8b8a5d3 100644 --- a/apps/backend/hub_platform/notifications/tests.py +++ b/apps/backend/hub_platform/notifications/tests.py @@ -125,27 +125,23 @@ class DeliveryTests(NotifierTestBase): self._dispatch_last_event() send.assert_not_called() # у оператора нет привязки; owner не адресат - def test_operator_notifications_follow_assignment_scope(self) -> None: + def test_operator_sees_operator_audience_but_not_owner_audience(self) -> None: operator = HumanUser.objects.get(email="a.kotova@edevs.tech") - sales = self.organization.departments.get(code="sales") - support = self.organization.departments.get(code="support") - sales_notification = notify( + operators_notification = notify( context=self.context, - department=sales, type=NotificationType.DIALOG_WAITING, audience=NotificationAudience.OPERATORS, - title="Sales dialog", + title="Waiting dialog", ) notify( context=self.context, - department=support, - type=NotificationType.DIALOG_WAITING, - audience=NotificationAudience.OPERATORS, - title="Support dialog", + type=NotificationType.LIMIT_REACHED, + audience=NotificationAudience.OWNER, + title="Owner-only notice", ) self.assertEqual( list(visible_for(tenant_context_for(operator, self.organization)).values_list("id", flat=True)), - [sales_notification.id], + [operators_notification.id], ) diff --git a/apps/backend/hub_platform/platform/provisioning_service.py b/apps/backend/hub_platform/platform/provisioning_service.py index ed08b8b..c35ac58 100644 --- a/apps/backend/hub_platform/platform/provisioning_service.py +++ b/apps/backend/hub_platform/platform/provisioning_service.py @@ -17,7 +17,6 @@ from hub_platform.identity.models import ( OrganizationMembership, OrganizationStatus, ) -from hub_platform.identity.system_departments import ensure_system_departments from hub_platform.platform.errors import ( ProvisioningConflict, ProvisioningOwnerUnavailable, @@ -49,7 +48,7 @@ def provision_organization( ) -> ProvisioningResult: """Single write boundary for tenant provisioning (SPEC-HUB-0021 §4). - Coordinates identity, subscription, departments, audit and outbox in one + Coordinates identity, subscription, audit and outbox in one transaction. Tenant-owned rows are written under set_local_tenant(new_org.id) via tenant_atomic. No email/provider calls happen before commit (SPEC §4). """ @@ -85,7 +84,6 @@ def _run(command: ProvisioningCommand, operator: PlatformOperator) -> Provisioni provisioning.save(update_fields=["organization"]) with tenant_atomic(org.id): - ensure_system_departments(org) ensure_uncategorized_category(org) context = TenantContext.for_resource( org, @@ -196,7 +194,6 @@ def _provision_active_owner( organization=org, role=EmployeeRole.OWNER, position_title=_owner_position_title(), - primary_department=None, ) create_subscription( context=context, diff --git a/apps/backend/hub_platform/platform/tests/test_provisioning.py b/apps/backend/hub_platform/platform/tests/test_provisioning.py index 215a139..419558b 100644 --- a/apps/backend/hub_platform/platform/tests/test_provisioning.py +++ b/apps/backend/hub_platform/platform/tests/test_provisioning.py @@ -4,7 +4,6 @@ from django.test import TestCase from hub_platform.ai.models import AIAgent from hub_platform.identity.models import ( - Department, EmployeeRole, HumanUser, Organization, @@ -63,10 +62,6 @@ class ProvisionExistingOwnerTests(TestCase): ) self.assertTrue(SubscriptionActive(org)) self.assertTrue(UsagePeriod.objects.filter(subscription__organization=org).exists()) - self.assertEqual( - {d.code for d in Department.objects.filter(organization=org)}, - {"sales", "support"}, - ) record = OrganizationProvisioning.objects.get(idempotency_key="idem-acme") self.assertEqual(record.status, ProvisioningStatus.COMPLETED) self.assertEqual(record.organization_id, org.id) diff --git a/apps/backend/hub_platform/products/admin.py b/apps/backend/hub_platform/products/admin.py index 8e2fa08..34593a2 100644 --- a/apps/backend/hub_platform/products/admin.py +++ b/apps/backend/hub_platform/products/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from hub_platform.products.models import Product, ProductDepartment +from hub_platform.products.models import Product @admin.register(Product) @@ -8,9 +8,3 @@ class ProductAdmin(admin.ModelAdmin): list_display = ["code", "name", "organization", "status", "site_url"] list_filter = ["organization", "status"] search_fields = ["code", "name"] - - -@admin.register(ProductDepartment) -class ProductDepartmentAdmin(admin.ModelAdmin): - list_display = ["product", "department", "created_at"] - list_filter = ["department__organization", "department"] diff --git a/apps/backend/hub_platform/products/migrations/0012_delete_productdepartment.py b/apps/backend/hub_platform/products/migrations/0012_delete_productdepartment.py new file mode 100644 index 0000000..808d00b --- /dev/null +++ b/apps/backend/hub_platform/products/migrations/0012_delete_productdepartment.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + # Дроп после tenancy-гардов (RLS/триггеры ссылаются на эти таблицы). + ('tenancy', '0017_drop_commerce'), + ('products', '0011_remove_commerce'), + ] + + operations = [ + migrations.DeleteModel( + name='ProductDepartment', + ), + ] diff --git a/apps/backend/hub_platform/products/models.py b/apps/backend/hub_platform/products/models.py index a1cd1f5..5ae94e9 100644 --- a/apps/backend/hub_platform/products/models.py +++ b/apps/backend/hub_platform/products/models.py @@ -4,7 +4,6 @@ from django.core.exceptions import ValidationError from django.db import models from hub_platform.identity.crypto import EncryptedCharField -from hub_platform.tenancy.models import TenantRelationModel class ProductStatus(models.TextChoices): @@ -33,19 +32,3 @@ class Product(models.Model): def __str__(self) -> str: return f"{self.organization.slug}/{self.code}" - - -class ProductDepartment(TenantRelationModel): - tenant_relation_fields = ("product", "department") - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="department_links") - department = models.ForeignKey("identity.Department", on_delete=models.PROTECT, related_name="product_links") - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - constraints = [ - models.UniqueConstraint(fields=["product", "department"], name="uniq_product_department") - ] - - def clean(self) -> None: - if self.product.organization_id != self.department.organization_id: - raise ValidationError("Product and department must belong to the same organization") diff --git a/apps/backend/hub_platform/products/selectors.py b/apps/backend/hub_platform/products/selectors.py index bd88c86..6b0d194 100644 --- a/apps/backend/hub_platform/products/selectors.py +++ b/apps/backend/hub_platform/products/selectors.py @@ -8,7 +8,6 @@ def products_for_context(context: TenantContext) -> QuerySet[Product]: return ( Product.objects.filter(organization_id=context.organization_id) .prefetch_related( - "department_links__department", "channels__ai_agent", "channels__connections", ) diff --git a/apps/backend/hub_platform/products/serializers.py b/apps/backend/hub_platform/products/serializers.py index 3ea08e9..8cd0293 100644 --- a/apps/backend/hub_platform/products/serializers.py +++ b/apps/backend/hub_platform/products/serializers.py @@ -30,10 +30,6 @@ def product_payload(product: Product) -> dict[str, object]: "name": product.name, "status": product.status, "siteUrl": product.site_url, - "departments": [ - {"id": link.department_id, "code": link.department.code, "name": link.department.name} - for link in product.department_links.all() - ], "channels": [_channel_payload(channel) for channel in product.channels.all()], "createdAt": product.created_at.isoformat(), "updatedAt": product.updated_at.isoformat(), diff --git a/apps/backend/hub_platform/products/services.py b/apps/backend/hub_platform/products/services.py index e1e7618..004b36d 100644 --- a/apps/backend/hub_platform/products/services.py +++ b/apps/backend/hub_platform/products/services.py @@ -3,8 +3,7 @@ from dataclasses import dataclass from django.core.exceptions import ValidationError from django.db import transaction -from hub_platform.identity.models import Department, Organization -from hub_platform.products.models import Product, ProductDepartment, ProductStatus +from hub_platform.products.models import Product, ProductStatus from hub_platform.subscriptions.keys import QuotaKey from hub_platform.subscriptions.usage_service import record_usage from hub_platform.tenancy.context import TenantContext @@ -15,16 +14,6 @@ class ProductInput: code: str name: str site_url: str = "" - department_ids: tuple[int, ...] = () - - -def _departments(organization: Organization, department_ids: tuple[int, ...]) -> list[Department]: - if not department_ids: - return [] - departments = list(Department.objects.filter(organization=organization, id__in=department_ids)) - if len(departments) != len(set(department_ids)): - raise ValidationError({"departmentIds": "Department not found"}) - return departments @transaction.atomic @@ -48,8 +37,6 @@ def create_product(*, context: TenantContext, data: ProductInput) -> Product: aggregate_type="Product", aggregate_id=str(product.id), ) - for department in _departments(organization, data.department_ids): - ProductDepartment.objects.create(product=product, department=department) return product @@ -61,10 +48,6 @@ def update_product(*, context: TenantContext, product: Product, data: ProductInp product.site_url = data.site_url.strip() product.full_clean(exclude=["code"]) product.save(update_fields=["name", "site_url", "updated_at"]) - departments = _departments(product.organization, data.department_ids) - product.department_links.exclude(department__in=departments).delete() - for department in departments: - ProductDepartment.objects.get_or_create(product=product, department=department) return product diff --git a/apps/backend/hub_platform/products/tests.py b/apps/backend/hub_platform/products/tests.py index bf25cb2..da64db2 100644 --- a/apps/backend/hub_platform/products/tests.py +++ b/apps/backend/hub_platform/products/tests.py @@ -4,19 +4,18 @@ from django.test import TestCase from hub_platform.testing import TenantAPIClient as APIClient from hub_platform.identity.bootstrap import bootstrap_edevs_owner -from hub_platform.identity.models import Department, Organization -from hub_platform.products.models import Product, ProductDepartment, ProductStatus +from hub_platform.identity.models import Organization +from hub_platform.products.models import Product, ProductStatus class ProductApiTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.sales = Department.objects.get(organization=self.organization, code="sales") self.client = APIClient() self.client.login(username="owner@edevs.tech", password="temporary-password") - def test_create_product_is_active_and_assigned_to_department(self) -> None: + def test_create_product_is_active(self) -> None: response = self.client.post( "/api/v1/company/products/create/", data=json.dumps( @@ -24,7 +23,6 @@ class ProductApiTests(TestCase): "code": "academy", "name": "Academy", "siteUrl": "https://academy.edevs.tech", - "departmentIds": [self.sales.id], } ), content_type="application/json", @@ -33,8 +31,6 @@ class ProductApiTests(TestCase): self.assertEqual(response.status_code, 201) payload = response.json()["product"] self.assertEqual(payload["status"], ProductStatus.ACTIVE) - self.assertEqual(payload["departments"][0]["code"], "sales") - self.assertTrue(ProductDepartment.objects.filter(product_id=payload["id"], department=self.sales).exists()) def test_update_does_not_change_product_code(self) -> None: product = Product.objects.get(code="firepage") @@ -44,7 +40,6 @@ class ProductApiTests(TestCase): { "code": "changed-code", "name": "FirePage Updated", - "departmentIds": [self.sales.id], } ), content_type="application/json", diff --git a/apps/backend/hub_platform/products/views.py b/apps/backend/hub_platform/products/views.py index a80271b..d6a4d58 100644 --- a/apps/backend/hub_platform/products/views.py +++ b/apps/backend/hub_platform/products/views.py @@ -10,24 +10,11 @@ from hub_platform.products.models import Product, ProductStatus from hub_platform.products.selectors import product_for_context, products_for_context from hub_platform.products.serializers import product_payload from hub_platform.products.services import ProductInput, create_product, set_product_status, update_product -from hub_platform.identity.policy import accessible_department_ids - - -def _department_ids(value: object) -> tuple[int, ...]: - if value is None: - return () - if not isinstance(value, list) or any(not isinstance(item, int) for item in value): - raise ValidationError({"departmentIds": "List of department IDs required"}) - return tuple(value) - - def _input(body: dict[str, object], *, current: Product | None = None) -> ProductInput: - current_departments = [link.department_id for link in current.department_links.all()] if current else [] return ProductInput( code=str(body.get("code", current.code if current else "")), name=str(body.get("name", current.name if current else "")), site_url=str(body.get("siteUrl", current.site_url if current else "")), - department_ids=_department_ids(body.get("departmentIds", current_departments)), ) @@ -42,27 +29,12 @@ def _validation_error(error: Exception) -> Response: return Response({"detail": detail}, status=400) -def _departments_allowed(request: Request, capability: str, department_ids) -> bool: - accessible = accessible_department_ids(request.tenant_context.membership, capability) - return accessible is None or set(department_ids).issubset(accessible) - - -def _product_allowed(request: Request, capability: str, product: Product) -> bool: - accessible = accessible_department_ids(request.tenant_context.membership, capability) - return accessible is None or product.department_links.filter( - department_id__in=accessible - ).exists() - - class ProductListView(APIView): permission_classes = [HasCapability] required_capability = "products.view" def get(self, request: Request) -> Response: products = products_for_context(request.tenant_context) - department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) - if department_ids is not None: - products = products.filter(department_links__department_id__in=department_ids).distinct() return Response({"items": [product_payload(product) for product in products]}) @@ -73,8 +45,6 @@ class ProductCreateView(APIView): def post(self, request: Request) -> Response: profile = request.tenant_context.membership data = _input(request.data) - if not _departments_allowed(request, self.required_capability, data.department_ids): - return Response({"detail": "Product departments are outside access scope"}, status=403) try: product = create_product(context=request.tenant_context, data=data) except (ValidationError, IntegrityError) as error: @@ -100,8 +70,6 @@ class ProductDetailView(APIView): product = product_for_context(context=request.tenant_context, product_id=product_id) except Product.DoesNotExist: return Response({"detail": "Product not found"}, status=404) - if not _product_allowed(request, self.required_capability, product): - return Response({"detail": "Product not found"}, status=404) return Response({"product": product_payload(product)}) @@ -113,13 +81,7 @@ class ProductUpdateView(APIView): profile = request.tenant_context.membership try: product = product_for_context(context=request.tenant_context, product_id=product_id) - if not _product_allowed(request, self.required_capability, product): - raise Product.DoesNotExist data = _input(request.data, current=product) - if not _departments_allowed(request, self.required_capability, data.department_ids): - return Response( - {"detail": "Product departments are outside access scope"}, status=403 - ) product = update_product( context=request.tenant_context, product=product, data=data ) @@ -150,8 +112,6 @@ class ProductStatusView(APIView): product = Product.objects.get(id=product_id, organization=profile.organization) except Product.DoesNotExist: return Response({"detail": "Product not found"}, status=404) - if not _product_allowed(request, self.required_capability, product): - return Response({"detail": "Product not found"}, status=404) set_product_status( context=request.tenant_context, product=product, status=self.status_value ) diff --git a/apps/backend/hub_platform/subscriptions/tests/test_reservations.py b/apps/backend/hub_platform/subscriptions/tests/test_reservations.py index f748603..2972ea7 100644 --- a/apps/backend/hub_platform/subscriptions/tests/test_reservations.py +++ b/apps/backend/hub_platform/subscriptions/tests/test_reservations.py @@ -201,7 +201,6 @@ def _make_member(org, *, email: str, role: str = EmployeeRole.EMPLOYEE) -> None: organization=org, role=role, position_title="Member", - primary_department=None, ) @@ -225,7 +224,6 @@ class MembershipCountLimitTests(TestCase): organization=self.organization, role=EmployeeRole.OWNER, position_title="Owner", - primary_department=None, ) with self.assertRaises(QuotaExceeded): reserve_usage( diff --git a/apps/backend/hub_platform/support/messages.py b/apps/backend/hub_platform/support/messages.py index ec30cd4..2e8e8fb 100644 --- a/apps/backend/hub_platform/support/messages.py +++ b/apps/backend/hub_platform/support/messages.py @@ -126,7 +126,6 @@ def post_support_message( ) notify( context=context, - department=conversation.channel.department, type=NotificationType.DIALOG_WAITING, audience=NotificationAudience.OPERATORS, title=f"Нужен оператор · {client_label}", @@ -170,7 +169,6 @@ def post_support_message( ) notify( context=context, - department=conversation.channel.department, type=NotificationType.DIALOG_WAITING, audience=NotificationAudience.OPERATORS, title=f"AI передал диалог · {client_label}", diff --git a/apps/backend/hub_platform/support/public_views.py b/apps/backend/hub_platform/support/public_views.py index 4ebb559..a7c168e 100644 --- a/apps/backend/hub_platform/support/public_views.py +++ b/apps/backend/hub_platform/support/public_views.py @@ -71,7 +71,6 @@ class SupportSessionStartView(_Public): "organization", "integration", "integration__channel", - "integration__channel__department", "integration__channel__product", ).filter( organization=organization, diff --git a/apps/backend/hub_platform/support/session.py b/apps/backend/hub_platform/support/session.py index 06ce669..b6189d3 100644 --- a/apps/backend/hub_platform/support/session.py +++ b/apps/backend/hub_platform/support/session.py @@ -38,8 +38,6 @@ from hub_platform.tenancy.context import TenantContext def _deny_channel_policy(channel) -> errors.SupportSessionError | None: """Fail-closed проверки канала (SPEC §5.2).""" - if channel.department_id is None or channel.department.code != "support": - return errors.SupportSessionError(errors.CHANNEL_NOT_SUPPORT) if channel.product_id is None: return errors.SupportSessionError(errors.CHANNEL_PRODUCT_MISMATCH) if channel.requires_authenticated_product_identity and not channel.allow_anonymous_sessions: @@ -245,6 +243,8 @@ def _create_or_continue_conversation(*, organization, channel, snapshot, widget) conversation = Conversation.objects.create( organization=channel.organization, channel=channel, + # Диалог наследует группу канала при создании (ADR-HUB-0043 §3). + group=channel.group, connection=widget.integration, contact=None, support_identity_snapshot=snapshot, diff --git a/apps/backend/hub_platform/support/test_widget_messages.py b/apps/backend/hub_platform/support/test_widget_messages.py index 02d1f04..d4052ac 100644 --- a/apps/backend/hub_platform/support/test_widget_messages.py +++ b/apps/backend/hub_platform/support/test_widget_messages.py @@ -9,7 +9,7 @@ from hub_platform.testing import TenantAPIClient as APIClient from hub_platform.channels.models import Channel from hub_platform.identity.bootstrap import bootstrap_edevs_owner -from hub_platform.identity.models import Department, Organization +from hub_platform.identity.models import Organization from hub_platform.products.models import Product from hub_platform.support.models import ContractStatus, ProductSupportContract from hub_platform.support.test_helpers import FOXRAY_DATA, make_support_token @@ -19,7 +19,7 @@ SECRET = "test-support-secret-very-long-32bytes!!" def _setup_support_channel( - organization, support_department, product + organization, product ) -> tuple[Channel, ProductSupportContract]: product.support_token_secret = SECRET product.save(update_fields=["support_token_secret"]) @@ -58,7 +58,6 @@ def _setup_support_channel( organization=organization, code="foxray-support", name="FoxRay — поддержка", - department=support_department, product=product, requires_authenticated_product_identity=True, allow_anonymous_sessions=False, @@ -74,10 +73,9 @@ class SupportWidgetMessagesTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.support = Department.objects.get(organization=self.organization, code="support") self.product = Product.objects.get(organization=self.organization, code="foxray") self.channel, self.contract = _setup_support_channel( - self.organization, self.support, self.product + self.organization, self.product ) self.widget = create_web_widget(self.channel, name="FoxRay support widget") self.client = APIClient() diff --git a/apps/backend/hub_platform/support/tests.py b/apps/backend/hub_platform/support/tests.py index c81018a..61667c0 100644 --- a/apps/backend/hub_platform/support/tests.py +++ b/apps/backend/hub_platform/support/tests.py @@ -8,7 +8,7 @@ from hub_platform.testing import TenantAPIClient as APIClient from hub_platform.channels.models import Channel from hub_platform.identity.audit import AuditResult from hub_platform.identity.bootstrap import bootstrap_edevs_owner -from hub_platform.identity.models import AuditEvent, Department, Organization +from hub_platform.identity.models import AuditEvent, Organization from hub_platform.products.models import Product from hub_platform.support.models import ( ContractStatus, @@ -80,7 +80,6 @@ class SupportSessionTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") - self.support = Department.objects.get(organization=self.organization, code="support") self.product = Product.objects.get(organization=self.organization, code="foxray") self.product.support_token_secret = SECRET self.product.save(update_fields=["support_token_secret"]) @@ -89,7 +88,6 @@ class SupportSessionTests(TestCase): organization=self.organization, code="foxray-support", name="FoxRay — поддержка", - department=self.support, product=self.product, requires_authenticated_product_identity=True, allow_anonymous_sessions=False, @@ -172,11 +170,11 @@ class SupportSessionTests(TestCase): self._assert_denied_audit("CONTRACT_DISABLED") def test_wrong_channel_not_support(self) -> None: - # Sales-канал не может принимать support-токен. - sales = Department.objects.get(organization=self.organization, code="sales") + # Канал без support-политики (анонимные сессии разрешены) не может + # принимать support-токен. sales_channel = Channel.objects.create( organization=self.organization, code="foxray-sales-x", - name="FoxRay sales", department=sales, product=self.product, + name="FoxRay sales", product=self.product, ) invalid_widget = create_web_widget( sales_channel, @@ -261,7 +259,7 @@ class SupportContractApiTests(TestCase): self.assertEqual(response.json()["contract"]["version"], 2) def test_operator_cannot_register_contract(self) -> None: - # Оператор назначен в sales (bootstrap) — не имеет прав на контракты. + # Оператор (роль EMPLOYEE) не имеет прав на управление контрактами. self.client.login(username="a.kotova@edevs.tech", password="Operator-Local-2026") response = self.client.post( "/api/v1/support/contracts/", diff --git a/apps/backend/hub_platform/support/views.py b/apps/backend/hub_platform/support/views.py index 28a0b1c..6b59dfd 100644 --- a/apps/backend/hub_platform/support/views.py +++ b/apps/backend/hub_platform/support/views.py @@ -5,7 +5,6 @@ from rest_framework.views import APIView from hub_platform.api.permissions import HasCapability, HasEntitlement from hub_platform.identity.audit import record_audit_event -from hub_platform.identity.policy import accessible_department_ids from hub_platform.support.models import ProductSupportContract from hub_platform.support.selectors import contract_for_context, contracts_for_context from hub_platform.support.serializers import ( @@ -145,10 +144,5 @@ class SupportSnapshotsBySubjectView(APIView): product_id=product.id, subject_key=subject_key, ) - department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) - if department_ids is not None: - snapshots = snapshots.filter( - conversations__channel__department_id__in=department_ids - ).distinct() snapshots = snapshots[:20] return Response({"items": [support_identity_snapshot_payload(s) for s in snapshots]}) diff --git a/apps/backend/hub_platform/support_portals/migrations/0008_remove_supportportal_department.py b/apps/backend/hub_platform/support_portals/migrations/0008_remove_supportportal_department.py new file mode 100644 index 0000000..f59936d --- /dev/null +++ b/apps/backend/hub_platform/support_portals/migrations/0008_remove_supportportal_department.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + # Дроп после tenancy-гардов (RLS/триггеры ссылаются на эти таблицы). + ('tenancy', '0017_drop_commerce'), + ('support_portals', '0007_portal_widget_references'), + ] + + operations = [ + migrations.RemoveField( + model_name='supportportal', + name='department', + ), + ] diff --git a/apps/backend/hub_platform/support_portals/models.py b/apps/backend/hub_platform/support_portals/models.py index 86f49d6..9b06dab 100644 --- a/apps/backend/hub_platform/support_portals/models.py +++ b/apps/backend/hub_platform/support_portals/models.py @@ -9,14 +9,9 @@ from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus class SupportPortal(TenantRelationModel): - """Публичный Help Center, управляемый внутри отдела поддержки.""" + """Публичный Help Center организации.""" - tenant_relation_fields = ("department", "widget_channel", "widget") - department = models.ForeignKey( - "identity.Department", - on_delete=models.PROTECT, - related_name="support_portals", - ) + tenant_relation_fields = ("widget_channel", "widget") public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) slug = models.SlugField(max_length=64, unique=True) hosted_domain = models.CharField(max_length=253, unique=True) @@ -80,10 +75,6 @@ class SupportPortal(TenantRelationModel): from hub_platform.support_portals.widget_validation import validate_portal_widget clean_portal_domains(self) - if self.department_id is not None and self.department.code != "support": - raise ValidationError( - {"department": "Support portal must belong to the support department"} - ) validate_portal_widget(self) @@ -140,10 +131,6 @@ class SupportPortalProduct(TenantRelationModel): raise ValidationError( {"support_channel": "Support channel must belong to the linked product"} ) - if channel.department_id is None or channel.department.code != "support": - raise ValidationError( - {"support_channel": "Support channel must belong to the support department"} - ) if ( not channel.requires_authenticated_product_identity or channel.allow_anonymous_sessions diff --git a/apps/backend/hub_platform/support_portals/portal_services.py b/apps/backend/hub_platform/support_portals/portal_services.py index 7259be6..7ca238c 100644 --- a/apps/backend/hub_platform/support_portals/portal_services.py +++ b/apps/backend/hub_platform/support_portals/portal_services.py @@ -5,7 +5,6 @@ from django.db import transaction from django.utils import timezone from hub_platform.channels.models import Channel -from hub_platform.identity.models import Department from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus from hub_platform.products.models import Product from hub_platform.subscriptions.keys import EntitlementKey, QuotaKey @@ -37,14 +36,9 @@ class PortalInput: @transaction.atomic def create_portal(*, context: TenantContext, data: PortalInput) -> SupportPortal: require_entitlement(context, EntitlementKey.SUPPORT_DEPARTMENT) - support_department = Department.objects.get( - organization=context.organization, - code="support", - ) widget = _widget(context, data.widget_id, data.widget_channel_id) portal = SupportPortal( organization=context.organization, - department=support_department, slug=data.slug.strip().lower(), hosted_domain=hosted_domain(data.slug.strip().lower()), name=data.name.strip(), @@ -94,7 +88,6 @@ def _widget( widgets = WebChatWidget.objects.select_related( "integration", "integration__channel", - "integration__channel__department", ).filter( organization=context.organization, mode=WebChatWidgetMode.ANONYMOUS, @@ -102,7 +95,6 @@ def _widget( integration__provider=IntegrationProvider.WEB, integration__status=IntegrationStatus.OK, integration__is_active=True, - integration__channel__department__code="support", integration__channel__is_active=True, integration__channel__requires_authenticated_product_identity=False, integration__channel__allow_anonymous_sessions=True, @@ -196,7 +188,7 @@ def replace_product_links( } channels = { item.id: item - for item in Channel.objects.select_related("department").filter( + for item in Channel.objects.filter( organization=context.organization, id__in=channel_ids, ) @@ -206,7 +198,6 @@ def replace_product_links( for item in WebChatWidget.objects.select_related( "integration", "integration__channel", - "integration__channel__department", ).filter( organization=context.organization, id__in=widget_ids, @@ -270,5 +261,3 @@ def replace_product_links( def _check_tenant(context: TenantContext, portal: SupportPortal) -> None: if portal.organization_id != context.organization_id: raise ValidationError({"portal": "Портал принадлежит другой организации"}) - if portal.department.code != "support": - raise ValidationError({"portal": "Портал не относится к отделу поддержки"}) diff --git a/apps/backend/hub_platform/support_portals/portal_views.py b/apps/backend/hub_platform/support_portals/portal_views.py index 27cbb6d..5f486c9 100644 --- a/apps/backend/hub_platform/support_portals/portal_views.py +++ b/apps/backend/hub_platform/support_portals/portal_views.py @@ -55,7 +55,6 @@ class PortalBaseView(APIView): "PUT": "support.operate", "DELETE": "support.operate", } - required_department_code = "support" def portal(self, request: Request, portal_id: int) -> SupportPortal | None: try: @@ -235,7 +234,6 @@ class PortalSupportChannelsView(PortalBaseView): integration__provider=IntegrationProvider.WEB, integration__status=IntegrationStatus.OK, integration__is_active=True, - integration__channel__department__code="support", integration__channel__product__isnull=False, integration__channel__is_active=True, ) @@ -254,7 +252,6 @@ class PortalSupportChannelsView(PortalBaseView): integration__provider=IntegrationProvider.WEB, integration__status=IntegrationStatus.OK, integration__is_active=True, - integration__channel__department__code="support", integration__channel__is_active=True, integration__channel__requires_authenticated_product_identity=False, integration__channel__allow_anonymous_sessions=True, diff --git a/apps/backend/hub_platform/support_portals/public_views.py b/apps/backend/hub_platform/support_portals/public_views.py index 6414cc7..43bb953 100644 --- a/apps/backend/hub_platform/support_portals/public_views.py +++ b/apps/backend/hub_platform/support_portals/public_views.py @@ -57,7 +57,6 @@ class PublicPortalView(APIView): .filter( id=route.resource_id, organization=organization, - department__code="support", status="PUBLISHED", ) .first() diff --git a/apps/backend/hub_platform/support_portals/selectors.py b/apps/backend/hub_platform/support_portals/selectors.py index 2a81147..c53c9a2 100644 --- a/apps/backend/hub_platform/support_portals/selectors.py +++ b/apps/backend/hub_platform/support_portals/selectors.py @@ -8,10 +8,8 @@ def portals_for_context(context: TenantContext) -> QuerySet[SupportPortal]: return ( SupportPortal.objects.filter( organization=context.organization, - department__code="support", ) .select_related( - "department", "widget_channel", "widget", "widget__integration", diff --git a/apps/backend/hub_platform/support_portals/serializers.py b/apps/backend/hub_platform/support_portals/serializers.py index 39b4a95..4956d4f 100644 --- a/apps/backend/hub_platform/support_portals/serializers.py +++ b/apps/backend/hub_platform/support_portals/serializers.py @@ -40,7 +40,6 @@ def portal_payload(portal: SupportPortal) -> dict: return { "id": portal.id, "publicId": str(portal.public_id), - "departmentCode": portal.department.code, "slug": portal.slug, "hostedDomain": portal.hosted_domain, "customDomain": portal.custom_domain or None, diff --git a/apps/backend/hub_platform/support_portals/tests/base.py b/apps/backend/hub_platform/support_portals/tests/base.py index 1c9d3a8..409a471 100644 --- a/apps/backend/hub_platform/support_portals/tests/base.py +++ b/apps/backend/hub_platform/support_portals/tests/base.py @@ -28,7 +28,6 @@ class SupportPortalTestCase(TestCase): organization=self.organization, code="foxray-help", name="Foxray support", - department=result.support_department, product=self.product, requires_authenticated_product_identity=True, allow_anonymous_sessions=False, diff --git a/apps/backend/hub_platform/support_portals/tests/test_management_api.py b/apps/backend/hub_platform/support_portals/tests/test_management_api.py index d0b08cf..763e0eb 100644 --- a/apps/backend/hub_platform/support_portals/tests/test_management_api.py +++ b/apps/backend/hub_platform/support_portals/tests/test_management_api.py @@ -4,11 +4,7 @@ from django.conf import settings from django.test import override_settings from hub_platform.channels.models import Channel -from hub_platform.identity.capabilities import ScopeType from hub_platform.identity.models import ( - AccessProfile, - AccessProfileCapability, - EmployeeAccessAssignment, EmployeeRole, HumanUser, Organization, @@ -27,7 +23,6 @@ class SupportPortalManagementTests(SupportPortalTestCase): organization=self.organization, code="foxray-portal-chat", name="FoxRay — чат портала", - department=self.channel.department, product=self.product, requires_authenticated_product_identity=False, allow_anonymous_sessions=True, @@ -81,7 +76,6 @@ class SupportPortalManagementTests(SupportPortalTestCase): def test_subscription_allows_multiple_active_portals(self) -> None: first = self.create_portal() self.assertEqual(first.status_code, 201, first.content) - self.assertEqual(first.json()["portal"]["departmentCode"], "support") self.assertEqual( first.json()["portal"]["hostedDomain"], f"foxray-help.{settings.CUS_HELP_BASE_DOMAIN}", @@ -126,7 +120,6 @@ class SupportPortalManagementTests(SupportPortalTestCase): organization=self.organization, code="anonymous-help", name="Anonymous", - department=self.channel.department, product=self.product, ) anonymous_widget = create_web_widget(anonymous_channel, name="Anonymous widget") @@ -232,30 +225,11 @@ class SupportPortalManagementTests(SupportPortalTestCase): email="other-portal-owner@example.com", password="temporary-password", ) - other_owner_membership = OrganizationMembership.objects.create( + OrganizationMembership.objects.create( organization=other_organization, user=other_owner, role=EmployeeRole.OWNER, ) - other_support = other_organization.departments.create( - code="support", - name="Поддержка", - ) - other_profile = AccessProfile.objects.create( - organization=other_organization, - name="Portal reader", - ) - AccessProfileCapability.objects.create( - access_profile=other_profile, - capability_code="support.view", - ) - EmployeeAccessAssignment.objects.create( - employee=other_owner_membership, - access_profile=other_profile, - scope_type=ScopeType.DEPARTMENT, - department=other_support, - assigned_by=other_owner_membership, - ) create_test_subscription(other_organization) self.client.force_authenticate(other_owner) self.client.set_tenant(other_organization) @@ -263,39 +237,3 @@ class SupportPortalManagementTests(SupportPortalTestCase): response = self.client.get(f"/api/v1/support/portals/{portal_id}/") self.assertEqual(response.status_code, 404, response.content) - - def test_support_scope_is_required_for_portal_access(self) -> None: - self.create_portal() - employee_user = HumanUser.objects.create_user( - email="scoped-portal-reader@example.com", - password="temporary-password", - ) - employee = OrganizationMembership.objects.create( - organization=self.organization, - user=employee_user, - role=EmployeeRole.EMPLOYEE, - ) - profile = AccessProfile.objects.create( - organization=self.organization, - name="Portal reader", - ) - AccessProfileCapability.objects.create( - access_profile=profile, - capability_code="support.view", - ) - sales = self.organization.departments.get(code="sales") - assignment = EmployeeAccessAssignment.objects.create( - employee=employee, - access_profile=profile, - scope_type=ScopeType.DEPARTMENT, - department=sales, - assigned_by=self.organization.memberships.get(user=self.owner), - ) - self.client.force_authenticate(employee_user) - denied = self.client.get("/api/v1/support/portals/") - self.assertEqual(denied.status_code, 403, denied.content) - - assignment.department = self.organization.departments.get(code="support") - assignment.save(update_fields=["department"]) - allowed = self.client.get("/api/v1/support/portals/") - self.assertEqual(allowed.status_code, 200, allowed.content) diff --git a/apps/backend/hub_platform/support_portals/tests/test_public_api.py b/apps/backend/hub_platform/support_portals/tests/test_public_api.py index 07a5ede..5f36223 100644 --- a/apps/backend/hub_platform/support_portals/tests/test_public_api.py +++ b/apps/backend/hub_platform/support_portals/tests/test_public_api.py @@ -27,7 +27,6 @@ class PublicSupportPortalTests(TestCase): organization=result.organization, code="edevs-help-chat", name="Edevs Help — чат", - department=result.support_department, requires_authenticated_product_identity=False, allow_anonymous_sessions=True, ) diff --git a/apps/backend/hub_platform/support_portals/widget_validation.py b/apps/backend/hub_platform/support_portals/widget_validation.py index 350a41a..9870557 100644 --- a/apps/backend/hub_platform/support_portals/widget_validation.py +++ b/apps/backend/hub_platform/support_portals/widget_validation.py @@ -4,13 +4,6 @@ from django.core.exceptions import ValidationError def validate_portal_widget(portal) -> None: if portal.widget_channel_id is not None: legacy_channel = portal.widget_channel - if ( - legacy_channel.department_id is None - or legacy_channel.department.code != "support" - ): - raise ValidationError( - {"widget_channel": "Portal widget must belong to the support department"} - ) if ( legacy_channel.requires_authenticated_product_identity or not legacy_channel.allow_anonymous_sessions @@ -24,12 +17,8 @@ def validate_portal_widget(portal) -> None: channel = widget.integration.channel if widget.mode != "ANONYMOUS": raise ValidationError({"widget": "Portal requires an anonymous widget"}) - if ( - channel is None - or channel.department_id is None - or channel.department.code != "support" - ): - raise ValidationError({"widget": "Portal widget must belong to support"}) + if channel is None: + raise ValidationError({"widget": "Portal widget must be bound to a channel"}) if portal.widget_channel_id is not None and channel.id != portal.widget_channel_id: raise ValidationError({"widget": "Widget and legacy channel must match"}) diff --git a/apps/backend/hub_platform/tenancy/middleware.py b/apps/backend/hub_platform/tenancy/middleware.py index 846f4e1..e810bf8 100644 --- a/apps/backend/hub_platform/tenancy/middleware.py +++ b/apps/backend/hub_platform/tenancy/middleware.py @@ -41,7 +41,7 @@ class TenantContextMiddleware: try: membership = ( OrganizationMembership.objects.select_related( - "organization", "user", "primary_department" + "organization", "user" ) .get( organization=organization, diff --git a/apps/backend/hub_platform/tenancy/migrations/0018_employee_groups_rls.py b/apps/backend/hub_platform/tenancy/migrations/0018_employee_groups_rls.py new file mode 100644 index 0000000..923ff85 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0018_employee_groups_rls.py @@ -0,0 +1,85 @@ +# ADR-HUB-0043: RLS, гранты и cross-tenant триггеры для групп сотрудников, +# плюс триггеры на новые group-FK каналов и диалогов. Стейл-записи Django для +# удалённых моделей отделов/профилей доступа вычищаются здесь же. +from django.db import migrations + +GROUP_TABLES = ("identity_employeegroup", "identity_employeegroupmember") + +FORWARD_RLS = """ +ALTER TABLE {table} OWNER TO custocrm_schema; +ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {table} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {table} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO custocrm_runtime_app; +GRANT ALL ON {table} TO custocrm_schema; +GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq + TO custocrm_runtime_app, custocrm_schema; +DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}; +CREATE POLICY custocrm_tenant_isolation ON {table} + FOR ALL TO custocrm_runtime_app + USING (organization_id = custocrm.current_organization_id()) + WITH CHECK (organization_id = custocrm.current_organization_id()); +DROP POLICY IF EXISTS custocrm_schema_access ON {table}; +CREATE POLICY custocrm_schema_access ON {table} + FOR ALL TO custocrm_schema USING (true) WITH CHECK (true); +""" + +# (child, parent, fk_column) — как в 0002_cross_tenant_constraints. +GROUP_FOREIGN_KEYS = ( + ("identity_employeegroupmember", "identity_employeegroup", "group_id"), + ("identity_employeegroupmember", "identity_employeeprofile", "employee_id"), + ("channels_channel", "identity_employeegroup", "group_id"), + ("conversations_conversation", "identity_employeegroup", "group_id"), +) + + +def apply_guards(apps, schema_editor): + for table in GROUP_TABLES: + schema_editor.execute(FORWARD_RLS.format(table=table)) + for index, (child, parent, fk_column) in enumerate(GROUP_FOREIGN_KEYS): + schema_editor.execute( + f"DROP TRIGGER IF EXISTS c04_group_tfk_{index} ON {child}" + ) + schema_editor.execute( + f"CREATE CONSTRAINT TRIGGER c04_group_tfk_{index} AFTER INSERT OR UPDATE ON {child} " + "DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION " + f"custocrm.enforce_tenant_fk('{parent}', '{fk_column}')" + ) + # Стейл-записи contenttypes/permissions удалённых моделей (иначе migrate + # --noinput оставляет их навсегда). + schema_editor.execute( + "DELETE FROM auth_permission WHERE content_type_id IN (" + "SELECT id FROM django_content_type WHERE " + "(app_label = 'identity' AND model IN ('department', 'accessprofile', " + "'accessprofilecapability', 'employeeaccessassignment')) OR " + "(app_label = 'ai' AND model = 'knowledgedepartment') OR " + "(app_label = 'products' AND model = 'productdepartment'))" + ) + schema_editor.execute( + "DELETE FROM django_content_type WHERE " + "(app_label = 'identity' AND model IN ('department', 'accessprofile', " + "'accessprofilecapability', 'employeeaccessassignment')) OR " + "(app_label = 'ai' AND model = 'knowledgedepartment') OR " + "(app_label = 'products' AND model = 'productdepartment')" + ) + + +def remove_guards(apps, schema_editor): + for index, (child, *_rest) in enumerate(GROUP_FOREIGN_KEYS): + schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_group_tfk_{index} ON {child}") + for table in GROUP_TABLES: + schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}") + schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_schema_access ON {table}") + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0017_drop_commerce"), + ("identity", "0020_employeegroup_employeegroupmember_and_more"), + ("channels", "0007_channel_group"), + ("conversations", "0010_conversation_group"), + ] + + operations = [migrations.RunPython(apply_guards, remove_guards)] diff --git a/apps/backend/hub_platform/tenancy/migrations/0019_support_portal_directory_without_department.py b/apps/backend/hub_platform/tenancy/migrations/0019_support_portal_directory_without_department.py new file mode 100644 index 0000000..5be386c --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0019_support_portal_directory_without_department.py @@ -0,0 +1,61 @@ +# ADR-HUB-0043: у портала больше нет отдела. Дроп колонки department_id +# (support_portals/0008) каскадно унёс вьюху custocrm.support_portal_directory — +# пересоздаём её без join на отделы и снимаем стейл-триггер sp_portal_department. +from django.db import migrations + +FORWARD_SQL = """ +DROP TRIGGER IF EXISTS sp_portal_department ON support_portals_supportportal; +DROP VIEW IF EXISTS custocrm.support_portal_directory; +CREATE VIEW custocrm.support_portal_directory +WITH (security_barrier = true) AS + SELECT portal.id AS resource_id, + portal.organization_id, + portal.hosted_domain AS lookup_key + FROM support_portals_supportportal portal + WHERE portal.status = 'PUBLISHED' + UNION ALL + SELECT portal.id AS resource_id, + portal.organization_id, + portal.custom_domain AS lookup_key + FROM support_portals_supportportal portal + WHERE portal.status = 'PUBLISHED' + AND portal.custom_domain <> '' + AND portal.custom_domain_verified_at IS NOT NULL; +ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema; +REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC; +GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform; +""" + +# На откате колонка department_id ещё не восстановлена (support_portals/0008 +# разворачивается позже этой миграции), поэтому старую версию вьюхи собрать +# нельзя. Оставляем безотдельную: reverse tenancy/0011 делает DROP VIEW без +# IF EXISTS и требует, чтобы вьюха существовала. +REVERSE_SQL = """ +DROP VIEW IF EXISTS custocrm.support_portal_directory; +CREATE VIEW custocrm.support_portal_directory +WITH (security_barrier = true) AS + SELECT portal.id AS resource_id, + portal.organization_id, + portal.hosted_domain AS lookup_key + FROM support_portals_supportportal portal + WHERE portal.status = 'PUBLISHED' + UNION ALL + SELECT portal.id AS resource_id, + portal.organization_id, + portal.custom_domain AS lookup_key + FROM support_portals_supportportal portal + WHERE portal.status = 'PUBLISHED' + AND portal.custom_domain <> '' + AND portal.custom_domain_verified_at IS NOT NULL; +ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema; +REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC; +GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0018_employee_groups_rls"), + ("support_portals", "0008_remove_supportportal_department"), + ] + operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)] diff --git a/apps/backend/hub_platform/tenancy/test_platform_grants.py b/apps/backend/hub_platform/tenancy/test_platform_grants.py index e5e8ed1..3277462 100644 --- a/apps/backend/hub_platform/tenancy/test_platform_grants.py +++ b/apps/backend/hub_platform/tenancy/test_platform_grants.py @@ -5,8 +5,8 @@ from django.test import TestCase from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME from hub_platform.ai.models import KnowledgeCategory +from hub_platform.identity.group_models import EmployeeGroup from hub_platform.identity.models import ( - Department, EmployeeRole, HumanUser, Organization, @@ -29,7 +29,7 @@ class PlatformProvisioningGrantsTests(TestCase): def test_provisioning_creates_organization_and_tenant_rows(self) -> None: # Pre-create an active owner user so provisioning takes the ACTIVE path - # and creates both departments and an OWNER membership. + # and creates an OWNER membership. HumanUser.objects.create_user(email="grants-owner@example.test") result = provision_organization( command=ProvisioningCommand( @@ -45,7 +45,6 @@ class PlatformProvisioningGrantsTests(TestCase): ) org = result.organization # Tenant-owned rows were created under matching tenant context. - self.assertEqual(Department.objects.filter(organization=org).count(), 2) self.assertTrue( KnowledgeCategory.objects.filter( organization=org, @@ -72,7 +71,7 @@ class PlatformProvisioningGrantsTests(TestCase): # the platform role has DML grants. A direct insert with no context yields # zero visible rows for the platform role. org = Organization.objects.create(name="Blind Org", slug="blind-org") - Department.objects.create(organization=org, code="sales", name="Sales") + EmployeeGroup.objects.create(organization=org, name="Операторы") # Reading via ORM (test superuser) sees the row; this documents that the # isolation boundary is the transaction-local context, tested elsewhere. - self.assertEqual(Department.objects.filter(organization=org).count(), 1) + self.assertEqual(EmployeeGroup.objects.filter(organization=org).count(), 1) diff --git a/apps/backend/hub_platform/tenancy/test_rls.py b/apps/backend/hub_platform/tenancy/test_rls.py index 0333c31..27c920f 100644 --- a/apps/backend/hub_platform/tenancy/test_rls.py +++ b/apps/backend/hub_platform/tenancy/test_rls.py @@ -4,10 +4,10 @@ from django.test import TransactionTestCase from hub_platform.ai.knowledge_categories import ensure_uncategorized_category from hub_platform.ai.models import AIAgent, Knowledge from hub_platform.channels.models import Channel +from hub_platform.identity.group_models import EmployeeGroup from hub_platform.identity.models import ( AuditEvent, AuditResult, - Department, EmployeeRole, HumanUser, Organization, @@ -35,13 +35,12 @@ class RowLevelSecurityTests(TransactionTestCase): code="second", name="Second product", ) - self.second_department = Department.objects.create( + self.second_group = EmployeeGroup.objects.create( organization=self.second, - code="foreign", name="Foreign", ) self.user = HumanUser.objects.create_user(email="rls@example.test") - OrganizationMembership.objects.create( + self.membership = OrganizationMembership.objects.create( organization=self.first, user=self.user, role=EmployeeRole.OWNER, @@ -180,10 +179,10 @@ class RowLevelSecurityTests(TransactionTestCase): set_local_tenant(self.first.id) with connection.cursor() as cursor: cursor.execute( - "INSERT INTO products_productdepartment " - "(organization_id, product_id, department_id, created_at) " + "INSERT INTO identity_employeegroupmember " + "(organization_id, group_id, employee_id, created_at) " "VALUES (%s, %s, %s, NOW())", - [self.first.id, self.first_product.id, self.second_department.id], + [self.first.id, self.second_group.id, self.membership.id], ) def test_cross_tenant_many_to_many_is_rejected(self) -> None: diff --git a/apps/backend/hub_platform/tenancy/tests.py b/apps/backend/hub_platform/tenancy/tests.py index 463262f..642089c 100644 --- a/apps/backend/hub_platform/tenancy/tests.py +++ b/apps/backend/hub_platform/tenancy/tests.py @@ -6,8 +6,8 @@ from rest_framework.test import APIClient as RawAPIClient from hub_platform.events.models import EventOwnership from hub_platform.events.services import DomainEvent, enqueue_event, tenant_context_for_event +from hub_platform.identity.group_models import EmployeeGroup from hub_platform.identity.models import ( - Department, EmployeeRole, HumanUser, Organization, @@ -28,11 +28,11 @@ class TenantHttpBoundaryTests(TestCase): ) self.first_membership = self._membership(self.first) self.second_membership = self._membership(self.second) - self.first_department = Department.objects.create( - organization=self.first, code="sales", name="First Sales" + self.first_group = EmployeeGroup.objects.create( + organization=self.first, name="Операторы" ) - self.second_department = Department.objects.create( - organization=self.second, code="sales", name="Second Sales" + self.second_group = EmployeeGroup.objects.create( + organization=self.second, name="Операторы" ) self.first_product = Product.objects.create( organization=self.first, code="first-product", name="First Product" diff --git a/apps/backend/hub_platform/webchat/models.py b/apps/backend/hub_platform/webchat/models.py index 028c2c7..8968a9a 100644 --- a/apps/backend/hub_platform/webchat/models.py +++ b/apps/backend/hub_platform/webchat/models.py @@ -77,9 +77,7 @@ class WebChatWidget(TenantRelationModel): raise ValidationError({"integration": "Web Chat widget requires a channel"}) if self.mode == WebChatWidgetMode.AUTHENTICATED_PRODUCT: if ( - channel.department_id is None - or channel.department.code != "support" - or channel.product_id is None + channel.product_id is None or not channel.requires_authenticated_product_identity or channel.allow_anonymous_sessions or channel.allow_self_reported_contact diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 0aeb936..cb5955f 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { edevsHubTheme } from "@edevs/ui"; import { api, setActiveOrganization } from "./api/client"; -import { canAccess, defaultRoute, hasCapability } from "./auth/access"; +import { canAccess, defaultRoute, isManager } from "./auth/access"; import { activateOrganization, clearOrganizationPreference } from "./auth/session"; import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; import { Shell } from "./layout/Shell"; @@ -12,7 +12,7 @@ import { pathFromRoute, routeFromPath } from "./router"; import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui"; import { useRouteNavigation } from "./useRouteNavigation"; import type { AiAgent } from "./features/ai/model"; -import type { AppData, AuthChallenge, AuthenticatedUser, Department, Employee, Product, SessionUser } from "./types"; +import type { AppData, AuthChallenge, AuthenticatedUser, Employee, EmployeeGroup, Product, SessionUser } from "./types"; export function App() { const initialRoute = useMemo(() => routeFromPath(window.location.pathname, window.location.search), []); @@ -23,7 +23,7 @@ export function App() { const [totpChallenge, setTotpChallenge] = useState(null); const [recovering, setRecovering] = useState(false); const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password"); - const [data, setData] = useState({ employees: [], departments: [], products: [], agents: [] }); + const [data, setData] = useState({ employees: [], groups: [], products: [], agents: [] }); const [dataError, setDataError] = useState(false); const navigation = useRouteNavigation(initialRoute, organizationPublicId); const { navigate } = navigation; @@ -40,14 +40,15 @@ export function App() { const loadData = useCallback(async () => { setDataError(false); try { - const [employees, departments, products] = await Promise.all([ - user && canAccess(user, "employees") + const manager = Boolean(user && isManager(user)); + const [employees, groups, products] = await Promise.all([ + manager ? api<{ items: Employee[] }>("/api/v1/employees/") : Promise.resolve({ items: [] }), - user && canAccess(user, "departments") - ? api<{ items: Department[] }>("/api/v1/company/departments/") + manager + ? api<{ items: EmployeeGroup[] }>("/api/v1/company/groups/") : Promise.resolve({ items: [] }), - user && hasCapability(user, "products.view") + manager ? api<{ items: Product[] }>("/api/v1/company/products/") : Promise.resolve({ items: [] }), ]); @@ -56,7 +57,7 @@ export function App() { const agentsResponse = await api<{ items: AiAgent[] }>("/api/v1/ai/agents/"); agents = agentsResponse.items; } - setData({ employees: employees.items, departments: departments.items, products: products.items, agents }); + setData({ employees: employees.items, groups: groups.items, products: products.items, agents }); } catch { setDataError(true); } @@ -117,7 +118,7 @@ export function App() { clearOrganizationPreference(); setTotpChallenge(null); navigate("command", null, true, null, null); - setData({ employees: [], departments: [], products: [], agents: [] }); + setData({ employees: [], groups: [], products: [], agents: [] }); } if (resetting) { diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index d003f13..e527594 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -1,135 +1,66 @@ import { describe, expect, it } from "vitest"; -import type { SessionUser } from "../types"; -import { canAccess, defaultRoute, scopeDepartments } from "./access"; +import { canAccess, defaultRoute, hasCapability, isManager } from "./access"; +import type { Role, SessionUser } from "../types"; -const baseUser: Omit = { - id: 1, - email: "employee@example.test", - fullName: "Employee", - organizationPublicId: "00000000-0000-4000-8000-000000000001", - positionTitle: "Specialist", - organization: "example", - organizationName: "Example", - organizationLogoUrl: null, - department: null, - mustChangePassword: false, - totpRequired: false, - totpEnabled: false, - deliveryMode: "CLOUD", -}; - -function userWith( - capabilities: string[], - departmentCode: string | null = null, -): SessionUser { - const membership = { - id: baseUser.id, - organizationPublicId: baseUser.organizationPublicId, - organization: baseUser.organization, - organizationName: baseUser.organizationName, - organizationLogoUrl: baseUser.organizationLogoUrl, - role: "EMPLOYEE" as const, - positionTitle: baseUser.positionTitle, - department: baseUser.department, - totpRequired: baseUser.totpRequired, - capabilities, - accessScopes: [ - { - scopeType: departmentCode ? "DEPARTMENT" as const : "ORGANIZATION" as const, - departmentId: departmentCode ? 10 : null, - departmentCode, - capabilities, - }, - ], - }; +function userWith(role: Role, capabilities: string[] = []): SessionUser { return { - ...baseUser, - ...membership, - memberships: [membership], + id: 1, + email: "user@example.test", + fullName: "Test User", + mustChangePassword: false, + totpEnabled: false, + deliveryMode: "CLOUD", + memberships: [], + organizationPublicId: "org-1", + organization: "org", + organizationName: "Org", + organizationLogoUrl: null, + role, + positionTitle: "Специалист", + totpRequired: false, + capabilities, + groups: [], }; } -describe("effective access navigation", () => { - it("uses organization capabilities instead of the system role", () => { - const user = userWith(["company.view", "employees.view", "employees.manage"]); - expect(canAccess(user, "command")).toBe(true); - expect(canAccess(user, "employees")).toBe(true); - expect(canAccess(user, "accessProfiles")).toBe(true); - expect(canAccess(user, "administrationOrganization")).toBe(false); - expect(canAccess(user, "integrations")).toBe(false); - expect(defaultRoute(user)).toBe("command"); +describe("role-based navigation (SPEC-HUB-0031 §3)", () => { + it("gives owner and admin identical full access", () => { + for (const role of ["OWNER", "ADMIN"] as const) { + const user = userWith(role); + expect(isManager(user)).toBe(true); + expect(canAccess(user, "command")).toBe(true); + expect(canAccess(user, "employees")).toBe(true); + expect(canAccess(user, "channels")).toBe(true); + expect(canAccess(user, "aiKnowledge")).toBe(true); + expect(canAccess(user, "administrationOrganization")).toBe(true); + expect(defaultRoute(user)).toBe("command"); + } }); - it("opens administration subsections only with their capabilities", () => { - const settingsUser = userWith(["settings.view"]); - const auditUser = userWith(["audit.view"]); - expect(canAccess(settingsUser, "administrationOrganization")).toBe(true); - expect(canAccess(settingsUser, "administrationSubscription")).toBe(true); - expect(canAccess(settingsUser, "administrationAudit")).toBe(false); - expect(canAccess(auditUser, "administrationOrganization")).toBe(false); - expect(canAccess(auditUser, "administrationAudit")).toBe(true); + it("limits employee to the chat plus self-service", () => { + const user = userWith("EMPLOYEE"); + expect(isManager(user)).toBe(false); + expect(canAccess(user, "salesDialogs")).toBe(true); + expect(canAccess(user, "supportDialogs")).toBe(true); + expect(canAccess(user, "profile")).toBe(true); + expect(canAccess(user, "settings")).toBe(true); + expect(canAccess(user, "employees")).toBe(false); + expect(canAccess(user, "channels")).toBe(false); + expect(canAccess(user, "aiAgents")).toBe(false); + expect(canAccess(user, "salesClients")).toBe(false); + expect(defaultRoute(user)).toBe("salesDialogs"); }); - it("does not expose the cloud subscription in a self-hosted installation", () => { - const user = userWith(["settings.view"]); + it("hides cloud subscription section outside the cloud delivery", () => { + const user = userWith("OWNER"); user.deliveryMode = "SELF_HOSTED"; expect(canAccess(user, "administrationSubscription")).toBe(false); }); - it("keeps sales and support department scopes isolated", () => { - const capabilities = ["conversations.view", "customers.view"]; - const sales = userWith(capabilities, "sales"); - const support = userWith(["conversations.view", "support.view"], "support"); - - expect(canAccess(sales, "salesDialogs")).toBe(true); - expect(canAccess(sales, "supportDialogs")).toBe(false); - expect(canAccess(sales, "supportPortals")).toBe(false); - expect(canAccess(support, "supportDialogs")).toBe(true); - expect(canAccess(support, "supportPortals")).toBe(true); - expect(canAccess(support, "salesDialogs")).toBe(false); - }); - - it("combines multiple department assignments", () => { - const user = userWith([], "sales"); - user.capabilities = ["conversations.view"]; - user.accessScopes = [ - { scopeType: "DEPARTMENT", departmentId: 10, departmentCode: "sales", capabilities: ["conversations.view"] }, - { scopeType: "DEPARTMENT", departmentId: 20, departmentCode: "support", capabilities: ["conversations.view"] }, - ]; - expect(canAccess(user, "salesDialogs")).toBe(true); - expect(canAccess(user, "supportDialogs")).toBe(true); - expect(defaultRoute(user)).toBe("salesDialogs"); - }); - - it("falls back to self-service profile when no work capability is assigned", () => { - const user = userWith([]); - expect(canAccess(user, "profile")).toBe(true); - expect(defaultRoute(user)).toBe("profile"); - }); - - it("reports organization-wide access without a department list", () => { - expect(scopeDepartments(userWith(["channels.view"]), "channels.view")).toBeNull(); - }); - - it("returns the unique department subset for an assigned capability", () => { - const user = userWith(["channels.view"], "sales"); - user.accessScopes.push({ - scopeType: "DEPARTMENT", - departmentId: 10, - departmentCode: "sales", - capabilities: ["channels.view"], - }); - user.accessScopes.push({ - scopeType: "DEPARTMENT", - departmentId: 20, - departmentCode: "support", - capabilities: ["channels.view"], - }); - expect(scopeDepartments(user, "channels.view")).toEqual(["sales", "support"]); - }); - - it("returns an empty subset when the capability is not assigned", () => { - expect(scopeDepartments(userWith([]), "channels.view")).toEqual([]); + it("reads capabilities straight from the session payload", () => { + const user = userWith("EMPLOYEE", ["conversations.view"]); + expect(hasCapability(user, "conversations.view")).toBe(true); + expect(hasCapability(user, "ai.manage")).toBe(false); }); }); diff --git a/apps/internal-ui/src/auth/access.ts b/apps/internal-ui/src/auth/access.ts index 33c0b77..bd600df 100644 --- a/apps/internal-ui/src/auth/access.ts +++ b/apps/internal-ui/src/auth/access.ts @@ -1,62 +1,22 @@ import type { RouteKey, SessionUser } from "../types"; -type RouteAccess = { capability: string; departmentCode?: string }; +// Ролевая модель SPEC-HUB-0031 §3: OWNER и ADMIN идентичны и видят всё, +// EMPLOYEE работает только в чате. Backend — источник истины (deny-by-default); +// фронтенд лишь скрывает недоступное. -const ROUTE_ACCESS: Partial> = { - accessProfiles: { capability: "employees.manage" }, - administrationOrganization: { capability: "settings.view" }, - administrationSubscription: { capability: "settings.view" }, - administrationAudit: { capability: "audit.view" }, - command: { capability: "company.view" }, - departments: { capability: "departments.view" }, - employees: { capability: "employees.view" }, - employeeDetail: { capability: "employees.view" }, - aiAgents: { capability: "ai.view" }, - aiAgentCreate: { capability: "ai.manage" }, - aiAgentDetail: { capability: "ai.view" }, - aiKnowledge: { capability: "ai.view" }, - aiKnowledgeCreate: { capability: "ai.manage" }, - aiKnowledgeDetail: { capability: "ai.view" }, - aiUsage: { capability: "ai.view" }, - integrations: { capability: "integrations.view" }, - channels: { capability: "channels.view" }, - channelCreate: { capability: "channels.manage" }, - channelDetail: { capability: "channels.view" }, - salesDialogs: { capability: "conversations.view", departmentCode: "sales" }, - salesClients: { capability: "customers.view", departmentCode: "sales" }, - salesClientDetail: { capability: "customers.view", departmentCode: "sales" }, - supportOverview: { capability: "support.view", departmentCode: "support" }, - supportDialogs: { capability: "conversations.view", departmentCode: "support" }, - supportPortals: { capability: "support.view", departmentCode: "support" }, - supportPortalDetail: { capability: "support.view", departmentCode: "support" }, -}; +const EMPLOYEE_ROUTES: ReadonlySet = new Set([ + "salesDialogs", + "supportDialogs", + "profile", + "settings", +]); -export function hasCapability( - user: SessionUser, - capability: string, - departmentCode?: string, -): boolean { - if (!user.capabilities.includes(capability)) return false; - return user.accessScopes.some((scope) => { - if (!scope.capabilities.includes(capability)) return false; - if (scope.scopeType === "ORGANIZATION") return true; - return Boolean(departmentCode) && scope.departmentCode === departmentCode; - }); +export function isManager(user: SessionUser): boolean { + return user.role === "OWNER" || user.role === "ADMIN"; } -/** - * Отделы, которыми ограничен доступ пользователя к возможности. - * - * `null` — доступ на всю организацию. Непустой список означает, что данные - * приходят подмножеством, и интерфейс обязан сказать об этом явно, иначе - * пользователь примет отфильтрованный список за полный. - */ -export function scopeDepartments(user: SessionUser, capability: string): string[] | null { - const scopes = user.accessScopes.filter((scope) => scope.capabilities.includes(capability)); - if (scopes.some((scope) => scope.scopeType === "ORGANIZATION")) return null; - return [...new Set( - scopes.map((scope) => scope.departmentCode).filter((code): code is string => Boolean(code)), - )]; +export function hasCapability(user: SessionUser, capability: string): boolean { + return user.capabilities.includes(capability); } export function canAccess(user: SessionUser, route: RouteKey): boolean { @@ -64,23 +24,11 @@ export function canAccess(user: SessionUser, route: RouteKey): boolean { if (route === "administrationSubscription" && user.deliveryMode !== "CLOUD") { return false; } - const requirement = ROUTE_ACCESS[route]; - return requirement - ? hasCapability(user, requirement.capability, requirement.departmentCode) - : false; + if (isManager(user)) return true; + return EMPLOYEE_ROUTES.has(route); } -const LANDING_PRIORITY: RouteKey[] = [ - "command", - "salesDialogs", - "supportDialogs", - "supportOverview", - "employees", - "supportPortals", - "aiAgents", - "integrations", -]; - export function defaultRoute(user: SessionUser): RouteKey { - return LANDING_PRIORITY.find((route) => canAccess(user, route)) ?? "profile"; + if (isManager(user)) return "command"; + return "salesDialogs"; } diff --git a/apps/internal-ui/src/features/ai/agentOptions.ts b/apps/internal-ui/src/features/ai/agentOptions.ts index 2c51b57..d2f5fa1 100644 --- a/apps/internal-ui/src/features/ai/agentOptions.ts +++ b/apps/internal-ui/src/features/ai/agentOptions.ts @@ -1,18 +1,13 @@ import type { AgentLinkOption } from "../../shared/content-library/AgentLinkDialog"; import type { AiAgent } from "./model"; -/** - * Агенты для массового прикрепления материалов. `departmentCode` сужает список - * до агентов одного отдела: статьи портала поддержки доступны только агентам - * поддержки, поэтому предлагать остальных бессмысленно. - */ -export function agentLinkOptions(agents: AiAgent[], departmentCode?: string): AgentLinkOption[] { - return agents - .filter((agent) => departmentCode === undefined || agent.channel.department?.code === departmentCode) - .map((agent) => ({ - id: agent.id, - name: agent.name, - channelName: agent.channel.name, - departmentName: agent.channel.department?.name ?? null, - })); +/** Агенты для массового прикрепления материалов (библиотека знаний общая, + * ADR-HUB-0041 §8 — фильтров по отделам больше нет). */ +export function agentLinkOptions(agents: AiAgent[]): AgentLinkOption[] { + return agents.map((agent) => ({ + id: agent.id, + name: agent.name, + channelName: agent.channel.name, + groupName: agent.channel.group?.name ?? null, + })); } diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkActions.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkActions.tsx index e1d7085..b05cb85 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkActions.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkActions.tsx @@ -8,15 +8,9 @@ import { type AgentLinkOutcome, } from "../../../shared/content-library/AgentLinkDialog"; import { BulkSelectionBar } from "../../../shared/content-library/BulkSelectionBar"; -import { - bulkMoveKnowledge, - bulkReplaceKnowledgeVisibility, - isKnowledgeScopeConflict, - linkKnowledgeToAgent, -} from "./model"; -import { KnowledgeBulkDialog, type KnowledgeBulkMode } from "./KnowledgeBulkDialog"; -import { KnowledgeScopeConflictModal } from "./KnowledgeScopeConflictModal"; -import type { KnowledgeCategory, KnowledgeDepartmentReference, KnowledgeScopeConflict, KnowledgeVisibility } from "./types"; +import { bulkMoveKnowledge, linkKnowledgeToAgent } from "./model"; +import { KnowledgeBulkDialog } from "./KnowledgeBulkDialog"; +import type { KnowledgeCategory } from "./types"; const KNOWLEDGE_FORMS: [string, string, string] = ["знание", "знания", "знаний"]; @@ -24,37 +18,28 @@ export function KnowledgeBulkActions({ agents, canLinkAgents, categories, - departments, selectedIds, onClear, onComplete, - openAgent, }: { agents: AgentLinkOption[]; canLinkAgents: boolean; categories: KnowledgeCategory[]; - departments: KnowledgeDepartmentReference[]; selectedIds: Set; onClear: () => void; onComplete: () => Promise; - openAgent: (agentId: number) => void; }) { - const [mode, setMode] = useState(null); + const [moveOpen, setMoveOpen] = useState(false); const [agentOpen, setAgentOpen] = useState(false); const [outcome, setOutcome] = useState(null); const [categoryId, setCategoryId] = useState(null); - const [visibility, setVisibility] = useState("ORGANIZATION"); - const [departmentIds, setDepartmentIds] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [conflicts, setConflicts] = useState([]); - function open(nextMode: KnowledgeBulkMode) { - setMode(nextMode); + function openMoveDialog() { + setMoveOpen(true); setError(null); setCategoryId(null); - setVisibility(nextMode === "departments" ? "DEPARTMENTS" : "ORGANIZATION"); - setDepartmentIds([]); } function openAgentDialog() { @@ -64,20 +49,15 @@ export function KnowledgeBulkActions({ } async function submit() { - if (!mode) return; - if (mode === "move" && categoryId === null) { setError("Выберите категорию"); return; } - if (mode !== "move" && visibility === "DEPARTMENTS" && departmentIds.length === 0) { setError("Выберите хотя бы один отдел"); return; } + if (categoryId === null) { setError("Выберите категорию"); return; } setBusy(true); setError(null); try { - const knowledgeIds = [...selectedIds]; - if (mode === "move") await bulkMoveKnowledge({ knowledgeIds, categoryId: categoryId as number }); - else await bulkReplaceKnowledgeVisibility({ knowledgeIds, visibility, departmentIds: visibility === "DEPARTMENTS" ? departmentIds : [] }); - setMode(null); + await bulkMoveKnowledge({ knowledgeIds: [...selectedIds], categoryId }); + setMoveOpen(false); await onComplete(); } catch (caught) { - if (isKnowledgeScopeConflict(caught)) { setConflicts(caught.payload.conflicts); setMode(null); } - else setError(caught instanceof Error ? caught.message : "Не удалось применить операцию"); + setError(caught instanceof Error ? caught.message : "Не удалось применить операцию"); } finally { setBusy(false); } @@ -101,26 +81,18 @@ export function KnowledgeBulkActions({ return ( <> - - - + {canLinkAgents && } - {mode && ( + {moveOpen && ( setMode(null)} + onCancel={() => setMoveOpen(false)} onCategoryChange={setCategoryId} - onDepartmentChange={setDepartmentIds} onSubmit={() => void submit()} - onVisibilityChange={(value) => { setVisibility(value); if (value === "ORGANIZATION") setDepartmentIds([]); }} /> )} {agentOpen && ( @@ -135,7 +107,6 @@ export function KnowledgeBulkActions({ onSubmit={(agentId, action) => void submitAgentLink(agentId, action)} /> )} - {conflicts.length > 0 && setConflicts([])} openAgent={openAgent} />} ); } diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkDialog.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkDialog.tsx index d060b89..b537080 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkDialog.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkDialog.tsx @@ -1,71 +1,38 @@ import { Modal } from "antd"; import { Button } from "../../../shared/ui-controls"; -import { KnowledgeDepartmentPicker } from "./KnowledgeDepartmentPicker"; import { knowledgeCategoryPath } from "./knowledgeTree"; -import type { KnowledgeCategory, KnowledgeDepartmentReference, KnowledgeVisibility } from "./types"; - -export type KnowledgeBulkMode = "departments" | "move" | "visibility"; +import type { KnowledgeCategory } from "./types"; export function KnowledgeBulkDialog({ busy, categories, categoryId, - departmentIds, - departments, error, - mode, - visibility, onCancel, onCategoryChange, - onDepartmentChange, onSubmit, - onVisibilityChange, }: { busy: boolean; categories: KnowledgeCategory[]; categoryId: number | null; - departmentIds: number[]; - departments: KnowledgeDepartmentReference[]; error: string | null; - mode: KnowledgeBulkMode; - visibility: KnowledgeVisibility; onCancel: () => void; onCategoryChange: (categoryId: number | null) => void; - onDepartmentChange: (departmentIds: number[]) => void; onSubmit: () => void; - onVisibilityChange: (visibility: KnowledgeVisibility) => void; }) { - const title = mode === "move" ? "Переместить в категорию" : mode === "visibility" ? "Изменить доступность" : "Заменить отделы"; return ( - +
- {mode === "move" ? ( - - ) : ( - <> - {mode === "visibility" && ( -
- Область доступности -
- - -
-
- )} - {(mode === "departments" || visibility === "DEPARTMENTS") && ( - - )} - - )} + {error &&
{error}
}
diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeCreatePage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeCreatePage.tsx index 50aa0d5..f4d93f6 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeCreatePage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeCreatePage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import type { Department, RouteKey } from "../../../types"; +import type { RouteKey } from "../../../types"; import { EmptyState, LoadingState } from "../../../shared/ui"; import { createKnowledgeItem, deleteKnowledgeItem, uploadAttachment } from "./model"; import { KnowledgeAttachmentsCard } from "./KnowledgeAttachmentsCard"; @@ -10,11 +10,9 @@ import { emptyKnowledgeEditorState, knowledgeEditorError, knowledgeEditorRequest import { useKnowledgeCategories } from "./useKnowledgeCategories"; export function KnowledgeCreatePage({ - departments, openKnowledge, setRoute, }: { - departments: Department[]; openKnowledge: (knowledgeId: number) => void; setRoute: (route: RouteKey) => void; }) { @@ -60,7 +58,6 @@ export function KnowledgeCreatePage({ void; -}) { - const [open, setOpen] = useState(false); - const selected = departments.filter((department) => selectedIds.includes(department.id)); - - function toggle(id: number) { - onChange(selectedIds.includes(id) - ? selectedIds.filter((item) => item !== id) - : [...selectedIds, id]); - } - - const items = departments.map((department) => ({ - key: String(department.id), - label: ( - - ), - })); - - return ( - - ); -} diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailPage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailPage.tsx index f131e21..1fda6ee 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailPage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailPage.tsx @@ -4,29 +4,25 @@ import { useCallback, useEffect, useState } from "react"; import type { AiAgent } from "../model"; import { Button } from "../../../shared/ui-controls"; import { EmptyState, LoadingState } from "../../../shared/ui"; -import type { Department, RouteKey } from "../../../types"; +import type { RouteKey } from "../../../types"; import { deleteAttachment, deleteKnowledgeItem, fetchKnowledgeItem, - isKnowledgeScopeConflict, updateKnowledgeItem, uploadAttachment, type KnowledgeItem, - type KnowledgeScopeConflict, } from "./model"; import { KnowledgeAttachmentsCard } from "./KnowledgeAttachmentsCard"; import { KnowledgeDetailSidebar } from "./KnowledgeDetailSidebar"; import { KnowledgeEditorBreadcrumb } from "./KnowledgeEditorBreadcrumb"; import { KnowledgeEditorCard } from "./KnowledgeEditorCard"; import { knowledgeEditorError, knowledgeEditorRequest, knowledgeEditorState, type KnowledgeEditorState } from "./knowledgeEditorModel"; -import { KnowledgeScopeConflictModal } from "./KnowledgeScopeConflictModal"; import { useKnowledgeCategories } from "./useKnowledgeCategories"; export function KnowledgeDetailPage({ agents, canManage, - departments, knowledgeId, onLoaded, openAgent, @@ -34,7 +30,6 @@ export function KnowledgeDetailPage({ }: { agents: AiAgent[]; canManage: boolean; - departments: Department[]; knowledgeId: number | null; onLoaded: (title: string | null) => void; openAgent: (agentId: number) => void; @@ -48,7 +43,6 @@ export function KnowledgeDetailPage({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [deleteOpen, setDeleteOpen] = useState(false); - const [conflicts, setConflicts] = useState([]); const load = useCallback(async () => { if (!knowledgeId) return; @@ -78,8 +72,7 @@ export function KnowledgeDetailPage({ await action(); await load(); } catch (caught) { - if (isKnowledgeScopeConflict(caught)) setConflicts(caught.payload.conflicts); - else setError(caught instanceof Error ? caught.message : "Не удалось сохранить знание"); + setError(caught instanceof Error ? caught.message : "Не удалось сохранить знание"); } finally { setBusy(false); } @@ -102,7 +95,6 @@ export function KnowledgeDetailPage({ )} - {conflicts.length > 0 && setConflicts([])} openAgent={openAgent} />}
); } diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailSidebar.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailSidebar.tsx index 5c5b95f..0fc0ac9 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailSidebar.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailSidebar.tsx @@ -18,12 +18,12 @@ export function KnowledgeDetailSidebar({