✨ 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:
AndreyandClaude Fable 5 committed 2026-09-04 14:01:30 +03:00
1 parent 446a438776
commit 07f4cd5f34
228 files changed
+2438 -6587

No files matched your search

-4
View File
@@ -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/",
+1 -2
View File
@@ -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", ""),
)
+2 -69
View File
@@ -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}"
+24 -101
View File
@@ -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
@@ -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',
),
]
+2 -22
View File
@@ -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,
)
+7 -30
View File
@@ -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()
+5 -15
View File
@@ -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,
)
-1
View File
@@ -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")
-5
View File
@@ -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(),
+2 -12
View File
@@ -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 -29
View File
@@ -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):
+1 -1
View File
@@ -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'),
),
]
+4 -3
View File
@@ -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).
+12 -16
View File
@@ -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(),
+19 -33
View File
@@ -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
+35 -126
View File
@@ -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(
+2 -6
View File
@@ -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)})
+6 -8
View File
@@ -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))
+19 -30
View File
@@ -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}"
)
)
@@ -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"))
@@ -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'),
),
]
+6 -46
View File
@@ -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",
+45 -129
View File
@@ -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