✨ feat(identity): employee roles, position and org placement (ADR-HUB-0027 этап 1)

Системные роли OWNER/ADMIN/EMPLOYEE (удалён OPERATOR), обязательная должность
position_title и основной отдел primary_department. Инварианты: владелец на уровне
компании, ровно один OWNER на организацию. Двухфазная миграция OPERATOR->EMPLOYEE
без расширения прав; операционный доступ сохранён compatibility-адаптером.
Обновлены DTO/API/формы, список и карточка сотрудника, тесты модели и инвариантов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-07-12 21:25:10 +03:00
1 parent 192497ead0
commit cb3932e3f6
31 files changed
+377 -94

No files matched your search

+3 -2
View File
@@ -176,8 +176,9 @@ class AIAgentPermissionTests(TestCase):
EmployeeProfile.objects.create(
user=operator,
organization=Organization.objects.get(slug="edevs"),
role=EmployeeRole.OPERATOR,
department=None,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
primary_department=None,
)
self.client = APIClient()
self.client.login(username="operator@edevs.tech", password="operator-password")
@@ -12,11 +12,12 @@ def ensure_conversation_call_access(*, user, conversation: Conversation) -> None
or profile is None
or profile.is_blocked
or profile.organization_id != conversation.organization_id
or profile.role not in {EmployeeRole.OWNER, EmployeeRole.OPERATOR}
or profile.role not in {EmployeeRole.OWNER, EmployeeRole.EMPLOYEE}
):
raise CallAccessDenied("Нет доступа к звонкам этого диалога")
if profile.role == EmployeeRole.OPERATOR and (
profile.department_id is None or profile.department_id != conversation.channel.department_id
if profile.role == EmployeeRole.EMPLOYEE and (
profile.primary_department_id is None
or profile.primary_department_id != conversation.channel.department_id
):
raise CallAccessDenied("Нет доступа к звонкам этого отдела")
@@ -53,8 +53,9 @@ class CallDomainMixin:
EmployeeProfile.objects.create(
user=user,
organization=self.organization,
role=EmployeeRole.OPERATOR,
department=self.support_department,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор поддержки",
primary_department=self.support_department,
)
return user
+3 -2
View File
@@ -67,8 +67,9 @@ class ChannelRenamePermissionTests(TestCase):
EmployeeProfile.objects.create(
user=operator,
organization=organization,
role=EmployeeRole.OPERATOR,
department=None,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
primary_department=None,
)
self.client = APIClient()
self.client.login(username="operator@edevs.tech", password="operator-password")
+11 -2
View File
@@ -47,8 +47,17 @@ class DepartmentAdmin(admin.ModelAdmin):
@admin.register(EmployeeProfile)
class EmployeeProfileAdmin(admin.ModelAdmin):
list_display = ["user", "organization", "role", "department", "must_change_password", "totp_required", "blocked_at"]
list_filter = ["organization", "role", "department", "must_change_password", "totp_required"]
list_display = [
"user",
"organization",
"role",
"position_title",
"primary_department",
"must_change_password",
"totp_required",
"blocked_at",
]
list_filter = ["organization", "role", "primary_department", "must_change_password", "totp_required"]
search_fields = ["user__email", "user__full_name"]
@@ -11,9 +11,10 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
"email": user.email,
"fullName": user.full_name,
"role": profile.role,
"positionTitle": profile.position_title,
"organizationName": profile.organization.name,
"organization": profile.organization.slug,
"department": profile.department.code if profile.department else None,
"department": profile.primary_department.code if profile.primary_department else None,
"mustChangePassword": profile.must_change_password,
"totpRequired": profile.totp_required,
"totpEnabled": profile.totp_enabled,
@@ -70,7 +70,9 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
defaults={
"organization": organization,
"role": EmployeeRole.OWNER,
"department": sales_department,
"position_title": "Владелец",
# OWNER всегда на уровне компании (ADR-HUB-0027): без основного отдела.
"primary_department": None,
"totp_required": False,
},
)
@@ -91,9 +93,10 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
user=operator,
defaults={
"organization": organization,
"role": EmployeeRole.OPERATOR,
"role": EmployeeRole.EMPLOYEE,
"position_title": "Оператор отдела продаж",
"phone": "+7 916 245 14 02",
"department": sales_department,
"primary_department": sales_department,
},
)
if not operator_profile.phone:
@@ -9,7 +9,7 @@ from hub_platform.identity.models import Department, EmployeeRole
def _department_payload(department: Department) -> dict[str, object]:
employees = list(department.employees.select_related("user").all())
operators = [employee for employee in employees if employee.role == EmployeeRole.OPERATOR]
operators = [employee for employee in employees if employee.role == EmployeeRole.EMPLOYEE]
products = [link.product for link in department.product_links.select_related("product").order_by("product__name")]
# AI-агенты отдела: AIAgent живёт на канале обработки (ADR-HUB-0019),
# канал принадлежит отделу. Считаем активных агентов каналов этого отдела.
@@ -35,6 +35,6 @@ class DepartmentListView(APIView):
def get(self, request: Request) -> Response:
profile = request.user.employee_profile
departments = Department.objects.filter(organization=profile.organization).order_by("name")
if profile.role == EmployeeRole.OPERATOR:
departments = departments.filter(id=profile.department_id)
if profile.role == EmployeeRole.EMPLOYEE:
departments = departments.filter(id=profile.primary_department_id)
return Response({"items": [_department_payload(department) for department in departments]})
@@ -12,8 +12,9 @@ def employee_payload(profile: EmployeeProfile) -> dict[str, object]:
"email": profile.user.email,
"fullName": profile.user.full_name,
"role": profile.role,
"positionTitle": profile.position_title,
"phone": profile.phone,
"department": profile.department.code if profile.department else None,
"department": profile.primary_department.code if profile.primary_department else None,
"isActive": profile.user.is_active,
"isBlocked": profile.is_blocked,
"mustChangePassword": profile.must_change_password,
@@ -25,7 +26,7 @@ def employee_payload(profile: EmployeeProfile) -> dict[str, object]:
def get_owned_profile(request: Request, user_id: int) -> EmployeeProfile | None:
owner_profile = request.user.employee_profile
try:
return EmployeeProfile.objects.select_related("user", "department").get(
return EmployeeProfile.objects.select_related("user", "primary_department").get(
user_id=user_id,
organization=owner_profile.organization,
)
@@ -7,20 +7,37 @@ from rest_framework.views import APIView
from hub_platform.api.permissions import IsOwner
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.employee_support import employee_payload, get_owned_profile, temporary_password
from hub_platform.identity.models import Department, EmployeeProfile, EmployeeRole, HumanUser
from hub_platform.identity.models import (
POSITION_TITLE_MAX_LENGTH,
Department,
EmployeeProfile,
EmployeeRole,
HumanUser,
)
from hub_platform.identity.sessions import revoke_user_sessions
def _clean_position_title(raw: object) -> tuple[str, str | None]:
"""Нормализует должность (SPEC-HUB-0016 §5). Возвращает (значение, ошибка)."""
value = str(raw or "").strip()
if not value:
return "", "Position title is required"
if len(value) > POSITION_TITLE_MAX_LENGTH:
return value, f"Position title must be at most {POSITION_TITLE_MAX_LENGTH} characters"
return value, None
class EmployeeListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
profile = request.user.employee_profile
employees = EmployeeProfile.objects.select_related("user", "department").filter(
employees = EmployeeProfile.objects.select_related("user", "primary_department").filter(
organization=profile.organization
)
if profile.role == EmployeeRole.OPERATOR:
employees = employees.filter(department=profile.department)
# Compatibility (этап 1): обычный сотрудник видит только свой основной отдел.
if profile.role == EmployeeRole.EMPLOYEE:
employees = employees.filter(primary_department=profile.primary_department)
return Response({"items": [employee_payload(employee) for employee in employees.order_by("user__email")]})
@@ -34,8 +51,11 @@ class OperatorCreateView(APIView):
email = HumanUser.objects.normalize_email(str(body.get("email", "")))
full_name = str(body.get("fullName", ""))
provided_password = str(body.get("temporaryPassword", ""))
position_title, position_error = _clean_position_title(body.get("positionTitle"))
if not email:
return Response({"detail": "Email is required"}, status=400)
if position_error:
return Response({"detail": position_error}, status=400)
if len(provided_password) < 12:
return Response({"detail": "Temporary password must contain at least 12 characters"}, status=400)
@@ -50,8 +70,9 @@ class OperatorCreateView(APIView):
profile = EmployeeProfile.objects.create(
user=user,
organization=owner_profile.organization,
role=EmployeeRole.OPERATOR,
department=sales_department,
role=EmployeeRole.EMPLOYEE,
position_title=position_title,
primary_department=sales_department,
must_change_password=True,
)
record_audit_event(
@@ -78,8 +99,10 @@ class EmployeeUpdateView(APIView):
full_name = str(body.get("fullName", "")).strip()
email = HumanUser.objects.normalize_email(str(body.get("email", "")).strip())
phone = str(body.get("phone", "")).strip()
role = str(body.get("role", profile.role))
department_code = str(body.get("department", profile.department.code if profile.department else ""))
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 ""
department_code = str(body.get("department", current_department_code))
requested_role = str(body.get("role", profile.role))
totp_enabled = body.get("totpEnabled", profile.totp_enabled)
if not full_name:
@@ -88,8 +111,16 @@ class EmployeeUpdateView(APIView):
return Response({"detail": "Email is required"}, status=400)
if HumanUser.objects.exclude(id=profile.user_id).filter(email=email).exists():
return Response({"detail": "Email is already used"}, status=400)
if role not in EmployeeRole.values:
return Response({"detail": "Invalid role"}, status=400)
if position_error:
return Response({"detail": position_error}, status=400)
# Governance-инварианты этапа 1 (ADR-HUB-0027, SPEC-HUB-0016 §7/§10):
# владелец не меняется обычным update; повышение до ADMIN/OWNER откроется
# на этапах 2-3. Здесь допускается управление только обычными сотрудниками.
if profile.role != EmployeeRole.EMPLOYEE:
return Response({"detail": "This employee cannot be modified by this operation"}, status=403)
if requested_role != EmployeeRole.EMPLOYEE:
return Response({"detail": "Role changes are not available yet"}, status=403)
department = None
if department_code:
@@ -102,12 +133,14 @@ class EmployeeUpdateView(APIView):
profile.user.email = email
profile.user.save(update_fields=["full_name", "email"])
profile.phone = phone
profile.role = role
profile.department = department
profile.position_title = position_title
profile.primary_department = department
profile.totp_enabled = bool(totp_enabled)
if not profile.totp_enabled:
profile.totp_secret = ""
profile.save(update_fields=["phone", "role", "department", "totp_enabled", "totp_secret"])
profile.save(
update_fields=["phone", "position_title", "primary_department", "totp_enabled", "totp_secret"]
)
record_audit_event(
action="identity.employee_updated",
@@ -99,7 +99,9 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor
defaults={
"organization": organization,
"role": EmployeeRole.OWNER,
"department": sales_department,
"position_title": "Владелец",
# OWNER всегда на уровне компании (ADR-HUB-0027).
"primary_department": None,
"totp_required": False,
},
)
@@ -0,0 +1,79 @@
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M1-M2 (этап 1): additive schema + backfill.
# Роли OWNER/ADMIN/EMPLOYEE, обязательная должность (пока nullable), основной отдел
# и размещение владельца на уровне компании. Доступ существующих сотрудников не
# расширяется: OPERATOR → EMPLOYEE, права сохраняются compatibility-адаптером.
import django.db.models.deletion
from django.db import migrations, models
def operator_to_employee(apps, schema_editor):
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
# OPERATOR больше не является системной ролью (ADR-HUB-0027).
EmployeeProfile.objects.filter(role="OPERATOR").update(role="EMPLOYEE")
# Владелец всегда на уровне компании: снимаем основной отдел.
EmployeeProfile.objects.filter(role="OWNER").exclude(primary_department__isnull=True).update(
primary_department=None
)
def employee_to_operator(apps, schema_editor):
# Обратная миграция для rollback до cleanup: EMPLOYEE → OPERATOR.
# ADMIN и размещение владельца восстановить нельзя — остаются как есть.
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
EmployeeProfile.objects.filter(role="EMPLOYEE").update(role="OPERATOR")
class Migration(migrations.Migration):
dependencies = [
("identity", "0006_alter_employeeprofile_totp_secret"),
]
operations = [
migrations.RenameField(
model_name="employeeprofile",
old_name="department",
new_name="primary_department",
),
migrations.AddField(
model_name="employeeprofile",
name="position_title",
field=models.CharField(blank=True, max_length=120, null=True),
),
migrations.AlterField(
model_name="employeeprofile",
name="role",
field=models.CharField(
choices=[("OWNER", "Owner"), ("ADMIN", "Admin"), ("EMPLOYEE", "Employee")],
max_length=32,
),
),
migrations.AlterField(
model_name="employeeprofile",
name="primary_department",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="employees",
to="identity.department",
),
),
migrations.RunPython(operator_to_employee, employee_to_operator),
migrations.AddConstraint(
model_name="employeeprofile",
constraint=models.CheckConstraint(
check=models.Q(("role", "OWNER"), _negated=True)
| models.Q(("primary_department__isnull", True)),
name="owner_is_company_level",
),
),
migrations.AddConstraint(
model_name="employeeprofile",
constraint=models.UniqueConstraint(
condition=models.Q(("role", "OWNER")),
fields=("organization",),
name="uniq_owner_per_organization",
),
),
]
@@ -0,0 +1,29 @@
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M2 (этап 1): после явного заполнения должностей
# включается обязательность. Оставшиеся незаполненные значения нормализуются в "";
# непустая должность гарантируется application contract (SPEC-HUB-0016 §5).
from django.db import migrations, models
def coalesce_null_titles(apps, schema_editor):
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
EmployeeProfile.objects.filter(position_title__isnull=True).update(position_title="")
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("identity", "0007_employee_roles_position_department"),
]
operations = [
migrations.RunPython(coalesce_null_titles, noop),
migrations.AlterField(
model_name="employeeprofile",
name="position_title",
field=models.CharField(blank=True, default="", max_length=120),
),
]
+28 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager
from django.db import models
from django.db.models import Q
from django.utils import timezone
from hub_platform.identity.crypto import EncryptedCharField
@@ -94,17 +95,27 @@ class Department(models.Model):
return f"{self.organization.slug}/{self.code}"
# ADR-HUB-0027 / SPEC-HUB-0016 §5: лимит должности задаётся backend-константой.
POSITION_TITLE_MAX_LENGTH = 120
class EmployeeRole(models.TextChoices):
OWNER = "OWNER", "Owner"
OPERATOR = "OPERATOR", "Operator"
ADMIN = "ADMIN", "Admin"
EMPLOYEE = "EMPLOYEE", "Employee"
class EmployeeProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="employee_profile")
organization = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="employees")
role = models.CharField(max_length=32, choices=EmployeeRole.choices)
# Должность вводится вручную; обязательна для новых записей (SPEC-HUB-0016 §5).
# Пустая строка допускается на уровне БД только для legacy-записей до backfill.
position_title = models.CharField(max_length=POSITION_TITLE_MAX_LENGTH, blank=True, default="")
phone = models.CharField(max_length=32, blank=True)
department = models.ForeignKey(
# Основной отдел описывает оргструктуру, но не выдаёт прав (ADR-HUB-0027).
# null = сотрудник на верхнем уровне компании; OWNER всегда на уровне компании.
primary_department = models.ForeignKey(
Department,
on_delete=models.PROTECT,
related_name="employees",
@@ -118,6 +129,21 @@ class EmployeeProfile(models.Model):
blocked_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
# OWNER всегда на уровне компании (ADR-HUB-0027, инварианты размещения).
models.CheckConstraint(
check=~Q(role=EmployeeRole.OWNER) | Q(primary_department__isnull=True),
name="owner_is_company_level",
),
# В организации ровно один владелец (ADR-HUB-0027).
models.UniqueConstraint(
fields=["organization"],
condition=Q(role=EmployeeRole.OWNER),
name="uniq_owner_per_organization",
),
]
@property
def is_blocked(self) -> bool:
return self.blocked_at is not None
@@ -17,7 +17,10 @@ def is_owner(user: HumanUser | AnonymousUser) -> bool:
def is_operator(user: HumanUser | AnonymousUser) -> bool:
return get_employee_role(user) == EmployeeRole.OPERATOR
"""Compatibility-адаптер этапа 1 (ADR-HUB-0027): «оператор» — это рабочая функция,
которую после миграции выполняет обычный сотрудник (EMPLOYEE) в своём отделе.
Операционная авторизация остаётся прежней до этапа 3 (capability-модель)."""
return get_employee_role(user) == EmployeeRole.EMPLOYEE
def can_access_global_settings(user: HumanUser | AnonymousUser) -> bool:
@@ -25,22 +28,22 @@ def can_access_global_settings(user: HumanUser | AnonymousUser) -> bool:
def can_access_sales_workspace(user: HumanUser | AnonymousUser) -> bool:
return get_employee_role(user) in {EmployeeRole.OWNER, EmployeeRole.OPERATOR}
return get_employee_role(user) in {EmployeeRole.OWNER, EmployeeRole.EMPLOYEE}
def _operator_department_code(user: HumanUser | AnonymousUser) -> str | None:
"""Код отдела оператора (EmployeeProfile.department.code) или None.
"""Код основного отдела сотрудника (EmployeeProfile.primary_department.code) или None.
SPEC-HUB-0010 §8.1: оператор с доступом к нескольким отделам не поддерживается
(одиночный FK department). Для полного покрытия требуется DepartmentMembership.
Сейчас оператор строго в одном отделе: sales ИЛИ support (§10 изоляция inbox).
SPEC-HUB-0010 §8.1: доступ к нескольким отделам не поддерживается (одиночный FK
primary_department). Для полного покрытия требуется scoped-модель этапа 3.
Сейчас сотрудник строго в одном отделе: sales ИЛИ support (§10 изоляция inbox).
"""
if not user.is_authenticated:
return None
profile = getattr(user, "employee_profile", None)
if profile is None or profile.is_blocked or profile.department_id is None:
if profile is None or profile.is_blocked or profile.primary_department_id is None:
return None
return profile.department.code
return profile.primary_department.code
def is_sales_operator(user: HumanUser | AnonymousUser) -> bool:
+82 -11
View File
@@ -49,7 +49,7 @@ class BootstrapOwnerTests(TestCase):
self.assertTrue(result.owner.is_superuser)
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
self.assertEqual(operator.full_name, "Анна Котова")
self.assertEqual(operator.employee_profile.role, EmployeeRole.OPERATOR)
self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE)
self.assertEqual(operator.employee_profile.phone, "+7 916 245 14 02")
self.assertTrue(AuditEvent.objects.filter(action="identity.owner_bootstrapped").exists())
@@ -91,8 +91,8 @@ class PermissionTests(TestCase):
EmployeeProfile.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.OPERATOR,
department=self.sales,
role=EmployeeRole.EMPLOYEE,
primary_department=self.sales,
must_change_password=True,
)
@@ -403,6 +403,7 @@ class EmployeeEndpointTests(TestCase):
{
"email": "operator@edevs.tech",
"fullName": "Operator",
"positionTitle": "Оператор продаж",
"temporaryPassword": "operator-password",
}
),
@@ -412,7 +413,7 @@ class EmployeeEndpointTests(TestCase):
self.assertEqual(response.status_code, 201)
operator = HumanUser.objects.get(email="operator@edevs.tech")
self.assertTrue(operator.check_password("operator-password"))
self.assertEqual(operator.employee_profile.role, EmployeeRole.OPERATOR)
self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE)
self.assertTrue(operator.employee_profile.must_change_password)
self.assertTrue(AuditEvent.objects.filter(action="identity.operator_created").exists())
@@ -421,8 +422,8 @@ class EmployeeEndpointTests(TestCase):
EmployeeProfile.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.OPERATOR,
department=self.sales,
role=EmployeeRole.EMPLOYEE,
primary_department=self.sales,
)
self.client.logout()
self.client.login(username="operator@edevs.tech", password="operator-password")
@@ -446,8 +447,8 @@ class EmployeeEndpointTests(TestCase):
EmployeeProfile.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.OPERATOR,
department=self.sales,
role=EmployeeRole.EMPLOYEE,
primary_department=self.sales,
)
response = self.client.post(f"/api/v1/employees/{operator.id}/block/")
@@ -468,7 +469,8 @@ class EmployeeEndpointTests(TestCase):
"fullName": "Анна Котова",
"email": "anna.kotova@edevs.tech",
"phone": "+7 916 245 14 03",
"role": EmployeeRole.OPERATOR,
"positionTitle": "Оператор продаж",
"role": EmployeeRole.EMPLOYEE,
"department": "sales",
"totpEnabled": True,
}
@@ -553,8 +555,8 @@ class CompanyEndpointTests(TestCase):
EmployeeProfile.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.OPERATOR,
department=self.sales,
role=EmployeeRole.EMPLOYEE,
primary_department=self.sales,
)
self.client.logout()
self.client.login(username="operator@edevs.tech", password="operator-password")
@@ -637,3 +639,72 @@ class SeedIdempotencyTests(TestCase):
self.assertIn("seed skipped", out.getvalue())
# Ни новые офферы, ни цены не создаются и не изменяются на развёрнутой установке.
self.assertEqual(Offer.objects.count(), offers_before)
class EmployeeModelInvariantTests(TestCase):
"""ADR-HUB-0027 / SPEC-HUB-0016 §5,§7 — инварианты модели сотрудника после
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
размещение владельца на уровне компании и ровно один владелец на организацию."""
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.sales = Department.objects.get(code="sales")
def test_bootstrap_owner_is_company_level_with_title(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
self.assertEqual(owner.employee_profile.role, EmployeeRole.OWNER)
self.assertIsNone(owner.employee_profile.primary_department)
self.assertTrue(owner.employee_profile.position_title)
def test_bootstrapped_operator_is_employee_in_sales(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE)
self.assertEqual(operator.employee_profile.primary_department, self.sales)
self.assertTrue(operator.employee_profile.position_title)
def test_second_owner_is_rejected(self) -> None:
from django.db import IntegrityError, transaction
second = HumanUser.objects.create_user(email="owner2@edevs.tech", password="temporary-password")
with self.assertRaises(IntegrityError):
with transaction.atomic():
EmployeeProfile.objects.create(
user=second,
organization=self.organization,
role=EmployeeRole.OWNER,
position_title="Второй владелец",
primary_department=None,
)
def test_owner_with_primary_department_is_rejected(self) -> None:
from django.db import IntegrityError, transaction
user = HumanUser.objects.create_user(email="owner3@edevs.tech", password="temporary-password")
other_org = Organization.objects.create(name="Other", slug="other")
with self.assertRaises(IntegrityError):
with transaction.atomic():
EmployeeProfile.objects.create(
user=user,
organization=other_org,
role=EmployeeRole.OWNER,
position_title="Владелец",
primary_department=self.sales,
)
def test_create_employee_requires_position_title(self) -> None:
client = APIClient()
client.login(username="owner@edevs.tech", password="temporary-password")
response = client.post(
"/api/v1/employees/operators/",
data=json.dumps(
{
"email": "no-title@edevs.tech",
"fullName": "No Title",
"temporaryPassword": "temporary-password",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertFalse(HumanUser.objects.filter(email="no-title@edevs.tech").exists())
@@ -40,8 +40,8 @@ def _recipient_user_ids(notification: Notification) -> list[int]:
if notification.audience == NotificationAudience.OWNER:
profiles = profiles.filter(role=EmployeeRole.OWNER)
elif notification.audience == NotificationAudience.OPERATORS:
# Зеркало visible_for: аудиторию OPERATORS видят и операторы, и владелец.
profiles = profiles.filter(role__in=(EmployeeRole.OPERATOR, EmployeeRole.OWNER))
# Зеркало visible_for: аудиторию OPERATORS видят и сотрудники, и владелец.
profiles = profiles.filter(role__in=(EmployeeRole.EMPLOYEE, EmployeeRole.OWNER))
return list(profiles.values_list("user_id", flat=True))
+17 -17
View File
@@ -4,43 +4,43 @@ import type { RouteKey } from "../types";
import { canAccess, defaultRoute } from "./access";
const OWNER_ONLY: RouteKey[] = ["command", "departments", "employees", "employeeDetail", "products", "productDetail"];
const SALES_OPERATOR_ROUTES: RouteKey[] = ["salesOverview", "salesDialogs", "salesClients", "salesClientDetail", "salesOrders", "salesOrderDetail", "profile"];
const SUPPORT_OPERATOR_ROUTES: RouteKey[] = ["supportOverview", "supportDialogs", "profile"];
const SALES_EMPLOYEE_ROUTES: RouteKey[] = ["salesOverview", "salesDialogs", "salesClients", "salesClientDetail", "salesOrders", "salesOrderDetail", "profile"];
const SUPPORT_EMPLOYEE_ROUTES: RouteKey[] = ["supportOverview", "supportDialogs", "profile"];
describe("role access", () => {
it("grants OWNER every route", () => {
const all = [...OWNER_ONLY, ...SALES_OPERATOR_ROUTES, ...SUPPORT_OPERATOR_ROUTES];
const all = [...OWNER_ONLY, ...SALES_EMPLOYEE_ROUTES, ...SUPPORT_EMPLOYEE_ROUTES];
for (const route of all) {
expect(canAccess("OWNER", route)).toBe(true);
}
});
it("grants sales OPERATOR only the sales workspace and profile", () => {
for (const route of SALES_OPERATOR_ROUTES) {
expect(canAccess("OPERATOR", route, "sales")).toBe(true);
it("grants sales EMPLOYEE only the sales workspace and profile", () => {
for (const route of SALES_EMPLOYEE_ROUTES) {
expect(canAccess("EMPLOYEE", route, "sales")).toBe(true);
}
});
it("blocks sales OPERATOR from support workspace and owner-only routes", () => {
it("blocks sales EMPLOYEE from support workspace and owner-only routes", () => {
for (const route of OWNER_ONLY) {
expect(canAccess("OPERATOR", route, "sales")).toBe(false);
expect(canAccess("EMPLOYEE", route, "sales")).toBe(false);
}
expect(canAccess("OPERATOR", "supportDialogs", "sales")).toBe(false);
expect(canAccess("OPERATOR", "supportOverview", "sales")).toBe(false);
expect(canAccess("EMPLOYEE", "supportDialogs", "sales")).toBe(false);
expect(canAccess("EMPLOYEE", "supportOverview", "sales")).toBe(false);
});
it("grants support OPERATOR only the support workspace and profile (§10 изоляция)", () => {
for (const route of SUPPORT_OPERATOR_ROUTES) {
expect(canAccess("OPERATOR", route, "support")).toBe(true);
it("grants support EMPLOYEE only the support workspace and profile (§10 изоляция)", () => {
for (const route of SUPPORT_EMPLOYEE_ROUTES) {
expect(canAccess("EMPLOYEE", route, "support")).toBe(true);
}
// Support operator не видит sales inbox.
expect(canAccess("OPERATOR", "salesDialogs", "support")).toBe(false);
expect(canAccess("OPERATOR", "salesOverview", "support")).toBe(false);
expect(canAccess("EMPLOYEE", "salesDialogs", "support")).toBe(false);
expect(canAccess("EMPLOYEE", "salesOverview", "support")).toBe(false);
});
it("lands each role on its default route", () => {
expect(defaultRoute("OWNER")).toBe("command");
expect(defaultRoute("OPERATOR", "sales")).toBe("salesDialogs");
expect(defaultRoute("OPERATOR", "support")).toBe("supportDialogs");
expect(defaultRoute("EMPLOYEE", "sales")).toBe("salesDialogs");
expect(defaultRoute("EMPLOYEE", "support")).toBe("supportDialogs");
});
});
+6 -5
View File
@@ -1,10 +1,11 @@
import type { Role, RouteKey } from "../types";
// Матрица доступа SPEC-HUB-0004 §9 + SPEC-HUB-0010 §8.1/§10. OWNER имеет сквозной
// доступ; OPERATOR работает только в пространстве своего отдела и личном профиле.
// Department-scoped: sales operator видит только sales, support operator — только
// support (изоляция inbox §10). §8.1 (оператор в нескольких отделах) не покрыт —
// оператор строго в одном отделе (одиночный FK department на backend).
// доступ; EMPLOYEE работает только в пространстве своего отдела и личном профиле.
// Compat-адаптер этапа 1 (ADR-HUB-0027): операционный доступ по-прежнему определяется
// department. Полная capability-модель придёт на этапе 3. Department-scoped: sales →
// только sales, support → только support (изоляция inbox §10). §8.1 (несколько
// отделов) не покрыт — сотрудник строго в одном основном отделе (одиночный FK).
const SALES_ROUTES: ReadonlySet<RouteKey> = new Set<RouteKey>([
"salesOverview",
"salesDialogs",
@@ -23,7 +24,7 @@ export function canAccess(role: Role, route: RouteKey, department?: string | nul
if (role === "OWNER") return true;
if (route === "profile") return true;
if (department === "support") return SUPPORT_ROUTES.has(route);
// По умолчанию OPERATOR — sales-пространство (обратная совместимость).
// По умолчанию EMPLOYEE — sales-пространство (обратная совместимость).
return SALES_ROUTES.has(route);
}
@@ -30,7 +30,7 @@ export function EmployeeDetailHeader({
<RoleBadge role={form.role} />
<StatusPill status={status} />
</div>
<p>{form.email} · {departmentLabel}</p>
<p>{form.email} · {form.positionTitle || "Должность не указана"} · {departmentLabel}</p>
</div>
</div>
<div className="employee-header-actions">
@@ -29,6 +29,7 @@ export function EmployeeDetailSections({
<h3>Основные данные</h3>
<div className="employee-form-grid">
<FormField label="Имя" value={form.fullName} onChange={(value) => updateForm("fullName", value)} />
<FormField label="Должность" value={form.positionTitle} onChange={(value) => updateForm("positionTitle", value)} />
<FormField label="Телефон" value={form.phone} onChange={(value) => updateForm("phone", value)} />
<FormField label="Email · используется для входа" value={form.email} onChange={(value) => updateForm("email", value)} mono wide />
</div>
@@ -37,7 +38,7 @@ export function EmployeeDetailSections({
<section className="employee-detail-card">
<h3>Роль и доступ</h3>
<div className="employee-form-grid">
<SelectField label="Роль" value={form.role} onChange={(value) => updateForm("role", value)} options={[["OPERATOR", "OPERATOR"], ["OWNER", "OWNER"]]} />
<SelectField label="Роль" value={form.role} onChange={(value) => updateForm("role", value)} options={[["EMPLOYEE", "Сотрудник"], ["OWNER", "Владелец"]]} />
<SelectField label="Отдел" value={form.department} onChange={(value) => updateForm("department", value)} options={[["sales", "Отдел продаж"]]} />
</div>
<label className="employee-access-label">Доступные разделы</label>
@@ -48,7 +48,7 @@ function EmployeeRow({ employee, block, openEmployee, menuId, setMenuId }: { emp
};
return (
<tr>
<td><div className="person-cell"><Avatar employee={employee} /><button className="person-link" type="button" onClick={open}><strong>{employee.fullName || employee.email}</strong><small>{employee.email}</small></button></div></td>
<td><div className="person-cell"><Avatar employee={employee} /><button className="person-link" type="button" onClick={open}><strong>{employee.fullName || employee.email}</strong><small>{employee.positionTitle || "Должность не указана"} · {employee.email}</small></button></div></td>
<td><RoleBadge role={employee.role} /></td>
<td>{employee.department === "sales" ? "Продажи" : "—"}</td>
<td><StatusPill status={status} /></td>
@@ -25,7 +25,7 @@ export function EmployeesFilters({
<SearchInput className="employee-search" value={query} onChange={setQuery} placeholder="Поиск по имени или email…" />
<div className="filter-group">
<span>Роль</span>
<Segmented value={role} setValue={setRole} items={[["all", "Все"], ["OWNER", "OWNER"], ["OPERATOR", "OPERATOR"]]} />
<Segmented value={role} setValue={setRole} items={[["all", "Все"], ["OWNER", "Владелец"], ["EMPLOYEE", "Сотрудник"]]} />
</div>
<div className="filter-group">
<span>Статус</span>
@@ -35,7 +35,7 @@ export function EmployeesPage({ employees, reload, openEmployee }: { employees:
<PageHeader
title="Сотрудники"
text={<>Доступ к Hub · показано <b>{filtered.length}</b> из {employees.length}</>}
action={<Button icon="team" iconSize={16} type="button" variant="primary">Добавить оператора</Button>}
action={<Button icon="team" iconSize={16} type="button" variant="primary">Добавить сотрудника</Button>}
/>
<EmployeesFilters
query={query}
@@ -9,6 +9,7 @@ export type EmployeeForm = {
email: string;
fullName: string;
phone: string;
positionTitle: string;
role: Role;
totpEnabled: boolean;
};
@@ -36,6 +37,7 @@ export function employeeForm(employee: Employee): EmployeeForm {
fullName: employee.fullName || employee.email,
phone: employee.phone || employeeDetails(employee).phone,
email: employee.email,
positionTitle: employee.positionTitle,
role: employee.role,
department: employee.department ?? "sales",
totpEnabled: employee.totpEnabled,
@@ -1,6 +1,6 @@
import { Icon } from "../../../shared/icons";
import { EmptyState, LoadingState } from "../../../shared/ui";
import type { RouteKey } from "../../../types";
import type { Role, RouteKey } from "../../../types";
import { StatusBadge } from "../orders/StatusBadge";
import { actorView, attributionLabel, dateTimeLong, money, sourceBadge, statusBadge } from "../registry/model";
import { SaleActionPanel } from "./SaleActionPanel";
@@ -16,7 +16,7 @@ export function SaleDetailPage({
openDialog,
}: {
saleId: number | null;
role: "OWNER" | "OPERATOR";
role: Role;
setRoute: (route: RouteKey) => void;
openDialog: (conversationId: number) => void;
}) {
+5 -4
View File
@@ -94,10 +94,11 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
// Подменю AI показываем только там, где оно есть в baseline.
const isAiSection = route === "aiAgents" || route === "aiKnowledge" || route === "aiUsage";
const isAiFullWidth = route === "aiAgentCreate";
// OPERATOR работает в пространстве своего отдела (SPEC-HUB-0004 §9 + §0010 §10):
// sales operator → sales-sidebar, support operator → support-sidebar.
const showSalesSidebar = isSalesWorkspace || (user.role === "OPERATOR" && user.department !== "support" && !isSupportWorkspace);
const showSupportSidebar = isSupportWorkspace || (user.role === "OPERATOR" && user.department === "support");
// Сотрудник (EMPLOYEE) работает в пространстве своего отдела (SPEC-HUB-0004 §9 +
// §0010 §10): sales → sales-sidebar, support → support-sidebar. Compat-адаптер
// этапа 1 (ADR-HUB-0027): department по-прежнему определяет рабочее пространство.
const showSalesSidebar = isSalesWorkspace || (user.role === "EMPLOYEE" && user.department !== "support" && !isSupportWorkspace);
const showSupportSidebar = isSupportWorkspace || (user.role === "EMPLOYEE" && user.department === "support");
return (
<div className="hub-shell">
{showSupportSidebar ? <SupportSidebar route={route} user={user} setRoute={setRoute} waitingCount={waitingCount} /> : showSalesSidebar ? <SalesSidebar route={route} user={user} setRoute={setRoute} waitingCount={waitingCount} /> : <Sidebar route={route} user={user} setRoute={setRoute} />}
@@ -222,11 +222,16 @@
background: #f0f0f0;
}
.role-badge.operator {
.role-badge.employee {
color: #0958d9;
background: #e6f4ff;
}
.role-badge.admin {
color: #ad4e00;
background: #fff7e6;
}
.status-pill {
display: inline-flex;
align-items: center;
+7 -1
View File
@@ -32,8 +32,14 @@ export function StatusPill({ status }: { status: "normal" | "active" | "blocked"
return <span className="status-pill" style={{ background: bg, borderColor: border, color }}><span style={{ background: color }} />{label}</span>;
}
const ROLE_LABELS: Record<Role, string> = {
OWNER: "Владелец",
ADMIN: "Администратор",
EMPLOYEE: "Сотрудник",
};
export function RoleBadge({ role }: { role: Role }) {
return <span className={`role-badge ${role.toLowerCase()}`}>{role}</span>;
return <span className={`role-badge ${role.toLowerCase()}`}>{ROLE_LABELS[role] ?? role}</span>;
}
export function ProductTag({ product }: { product: Product }) {
+5 -1
View File
@@ -1,6 +1,8 @@
import type { AiAgent } from "./features/ai/model";
export type Role = "OWNER" | "OPERATOR";
// Системные роли ADR-HUB-0027. OPERATOR удалён как системная роль (этап 1);
// «оператор» — рабочая функция сотрудника (EMPLOYEE) в своём отделе.
export type Role = "OWNER" | "ADMIN" | "EMPLOYEE";
export type ProductStatus = "ACTIVE" | "DISABLED";
export type SessionUser = {
@@ -8,6 +10,7 @@ export type SessionUser = {
email: string;
fullName: string;
role: Role;
positionTitle: string;
organization: string;
organizationName: string;
department: string | null;
@@ -31,6 +34,7 @@ export type Employee = {
email: string;
fullName: string;
role: Role;
positionTitle: string;
phone: string;
department: string | null;
isActive: boolean;
+3 -1
View File
@@ -10,6 +10,7 @@ const OWNER = {
email: "owner@edevs.tech",
fullName: "Владелец",
role: "OWNER",
positionTitle: "Владелец",
organization: "edevs",
organizationName: "Edevs",
department: null,
@@ -18,7 +19,8 @@ const OWNER = {
totpEnabled: false,
};
const OPERATOR = { ...OWNER, id: 2, email: "operator@edevs.tech", fullName: "Оператор", role: "OPERATOR", department: "sales" };
// «Оператор» — рабочая функция обычного сотрудника (EMPLOYEE) в отделе (ADR-HUB-0027).
const OPERATOR = { ...OWNER, id: 2, email: "operator@edevs.tech", fullName: "Оператор", role: "EMPLOYEE", positionTitle: "Оператор отдела продаж", department: "sales" };
async function mockData(page: Page) {
await page.route("**/api/v1/employees/**", (route) => route.fulfill({ json: { items: [] } }));