mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(identity): группы сотрудников вместо отделов (этап 2 пивота, ADR-HUB-0043)
Отделы и 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
446a438776
commit
07f4cd5f34
228 files changed
+2438
-6587
No files matched your search
@@ -23,10 +23,6 @@ urlpatterns = [
|
||||
"api/v1/organizations/<uuid:organization_public_id>/employees/",
|
||||
include("hub_platform.identity.employee_urls"),
|
||||
),
|
||||
path(
|
||||
"api/v1/organizations/<uuid:organization_public_id>/access-profiles/",
|
||||
include("hub_platform.identity.access_urls"),
|
||||
),
|
||||
path("api/v1/organizations/<uuid:organization_public_id>/ai/", include("hub_platform.ai.urls")),
|
||||
path(
|
||||
"api/v1/organizations/<uuid:organization_public_id>/integrations/",
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
@@ -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", ""),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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":
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
UNCATEGORIZED_CATEGORY_NAME = "Без категории"
|
||||
|
||||
|
||||
class KnowledgeVisibility(models.TextChoices):
|
||||
ORGANIZATION = "ORGANIZATION", "Organization"
|
||||
DEPARTMENTS = "DEPARTMENTS", "Departments"
|
||||
@@ -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
|
||||
+48
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}),
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
@@ -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 {},
|
||||
|
||||
@@ -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")
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 'Гость'}",
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"]))
|
||||
# Интеграции отражены с группой.
|
||||
|
||||
@@ -16,4 +16,6 @@ urlpatterns = [
|
||||
path("<int:conversation_id>/request-contact/", views.ConversationRequestContactView.as_view(), name="conversation-request-contact"),
|
||||
path("<int:conversation_id>/close/", views.ConversationCloseView.as_view(), name="conversation-close"),
|
||||
path("<int:conversation_id>/spam/", views.ConversationSpamView.as_view(), name="conversation-spam"),
|
||||
path("<int:conversation_id>/group/", views.ConversationGroupView.as_view(), name="conversation-group"),
|
||||
path("<int:conversation_id>/assignee/", views.ConversationAssigneeView.as_view(), name="conversation-assignee"),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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("<int:profile_id>/", access_views.AccessProfileDetailView.as_view(), name="access-profile-detail"),
|
||||
]
|
||||
@@ -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)})
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"})
|
||||
@@ -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/<int:group_id>/", group_views.GroupDetailView.as_view(), name="group-detail"),
|
||||
path(
|
||||
"administration/",
|
||||
administration_views.OrganizationSettingsView.as_view(),
|
||||
|
||||
@@ -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]})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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": "Поддержка"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("<int:user_id>/block/", employee_security_views.EmployeeBlockView.as_view(), name="employee-block"),
|
||||
path("<int:user_id>/unblock/", employee_security_views.EmployeeUnblockView.as_view(), name="employee-unblock"),
|
||||
path("<int:user_id>/transfer-ownership/", ownership_views.OwnershipTransferView.as_view(), name="employee-transfer-ownership"),
|
||||
path("<int:user_id>/access-assignments/", access_views.EmployeeAccessAssignmentView.as_view(), name="employee-access-assignment"),
|
||||
path("<int:user_id>/access-assignments/<int:assignment_id>/", access_views.EmployeeAccessAssignmentRevokeView.as_view(), name="employee-access-assignment-revoke"),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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)})
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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})
|
||||
@@ -138,7 +138,6 @@ def _ensure_owner_membership(
|
||||
defaults={
|
||||
"role": EmployeeRole.OWNER,
|
||||
"position_title": "Владелец",
|
||||
"primary_department": None,
|
||||
"totp_required": False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
)
|
||||
-56
@@ -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"))
|
||||
+174
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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"])
|
||||
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user