diff --git a/apps/backend/hub_platform/ai/tests.py b/apps/backend/hub_platform/ai/tests.py index a207685..87ce6b5 100644 --- a/apps/backend/hub_platform/ai/tests.py +++ b/apps/backend/hub_platform/ai/tests.py @@ -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") diff --git a/apps/backend/hub_platform/calls/permissions.py b/apps/backend/hub_platform/calls/permissions.py index 4a260cd..e7b51ab 100644 --- a/apps/backend/hub_platform/calls/permissions.py +++ b/apps/backend/hub_platform/calls/permissions.py @@ -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("Нет доступа к звонкам этого отдела") diff --git a/apps/backend/hub_platform/calls/tests/helpers.py b/apps/backend/hub_platform/calls/tests/helpers.py index b171feb..08ef294 100644 --- a/apps/backend/hub_platform/calls/tests/helpers.py +++ b/apps/backend/hub_platform/calls/tests/helpers.py @@ -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 diff --git a/apps/backend/hub_platform/channels/tests.py b/apps/backend/hub_platform/channels/tests.py index 398931c..f402383 100644 --- a/apps/backend/hub_platform/channels/tests.py +++ b/apps/backend/hub_platform/channels/tests.py @@ -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") diff --git a/apps/backend/hub_platform/identity/admin.py b/apps/backend/hub_platform/identity/admin.py index ac9f705..76d2950 100644 --- a/apps/backend/hub_platform/identity/admin.py +++ b/apps/backend/hub_platform/identity/admin.py @@ -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"] diff --git a/apps/backend/hub_platform/identity/auth/common.py b/apps/backend/hub_platform/identity/auth/common.py index b4895d4..4da47ee 100644 --- a/apps/backend/hub_platform/identity/auth/common.py +++ b/apps/backend/hub_platform/identity/auth/common.py @@ -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, diff --git a/apps/backend/hub_platform/identity/bootstrap.py b/apps/backend/hub_platform/identity/bootstrap.py index 83af1ca..1387dca 100644 --- a/apps/backend/hub_platform/identity/bootstrap.py +++ b/apps/backend/hub_platform/identity/bootstrap.py @@ -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: diff --git a/apps/backend/hub_platform/identity/company_views.py b/apps/backend/hub_platform/identity/company_views.py index bdc8d94..2628272 100644 --- a/apps/backend/hub_platform/identity/company_views.py +++ b/apps/backend/hub_platform/identity/company_views.py @@ -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]}) diff --git a/apps/backend/hub_platform/identity/employee_support.py b/apps/backend/hub_platform/identity/employee_support.py index 6b04cab..07d4e74 100644 --- a/apps/backend/hub_platform/identity/employee_support.py +++ b/apps/backend/hub_platform/identity/employee_support.py @@ -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, ) diff --git a/apps/backend/hub_platform/identity/employee_views.py b/apps/backend/hub_platform/identity/employee_views.py index 07ba28a..0a3dc83 100644 --- a/apps/backend/hub_platform/identity/employee_views.py +++ b/apps/backend/hub_platform/identity/employee_views.py @@ -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", diff --git a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py index dd8bc11..c98c2aa 100644 --- a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py +++ b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py @@ -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, }, ) diff --git a/apps/backend/hub_platform/identity/migrations/0007_employee_roles_position_department.py b/apps/backend/hub_platform/identity/migrations/0007_employee_roles_position_department.py new file mode 100644 index 0000000..2fd5e65 --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0007_employee_roles_position_department.py @@ -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", + ), + ), + ] diff --git a/apps/backend/hub_platform/identity/migrations/0008_enforce_position_title.py b/apps/backend/hub_platform/identity/migrations/0008_enforce_position_title.py new file mode 100644 index 0000000..047893d --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0008_enforce_position_title.py @@ -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), + ), + ] diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/hub_platform/identity/models.py index 3890d7e..2f963d3 100644 --- a/apps/backend/hub_platform/identity/models.py +++ b/apps/backend/hub_platform/identity/models.py @@ -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 diff --git a/apps/backend/hub_platform/identity/permissions.py b/apps/backend/hub_platform/identity/permissions.py index 5590824..7e028eb 100644 --- a/apps/backend/hub_platform/identity/permissions.py +++ b/apps/backend/hub_platform/identity/permissions.py @@ -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: diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/hub_platform/identity/tests.py index 0a4a8cc..ac93ed9 100644 --- a/apps/backend/hub_platform/identity/tests.py +++ b/apps/backend/hub_platform/identity/tests.py @@ -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()) diff --git a/apps/backend/hub_platform/notifications/delivery.py b/apps/backend/hub_platform/notifications/delivery.py index 658ccea..c529f82 100644 --- a/apps/backend/hub_platform/notifications/delivery.py +++ b/apps/backend/hub_platform/notifications/delivery.py @@ -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)) diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index 2e724eb..2a66d73 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -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"); }); }); diff --git a/apps/internal-ui/src/auth/access.ts b/apps/internal-ui/src/auth/access.ts index 7ff89e9..a17a9c7 100644 --- a/apps/internal-ui/src/auth/access.ts +++ b/apps/internal-ui/src/auth/access.ts @@ -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 = new Set([ "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); } diff --git a/apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx b/apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx index 824b89e..02e1134 100644 --- a/apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx +++ b/apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx @@ -30,7 +30,7 @@ export function EmployeeDetailHeader({ -

{form.email} · {departmentLabel}

+

{form.email} · {form.positionTitle || "Должность не указана"} · {departmentLabel}

diff --git a/apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx b/apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx index 67df5f8..5aa31de 100644 --- a/apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx +++ b/apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx @@ -29,6 +29,7 @@ export function EmployeeDetailSections({

Основные данные

updateForm("fullName", value)} /> + updateForm("positionTitle", value)} /> updateForm("phone", value)} /> updateForm("email", value)} mono wide />
@@ -37,7 +38,7 @@ export function EmployeeDetailSections({

Роль и доступ

- updateForm("role", value)} options={[["OPERATOR", "OPERATOR"], ["OWNER", "OWNER"]]} /> + updateForm("role", value)} options={[["EMPLOYEE", "Сотрудник"], ["OWNER", "Владелец"]]} /> updateForm("department", value)} options={[["sales", "Отдел продаж"]]} />
diff --git a/apps/internal-ui/src/features/employees/EmployeeTable.tsx b/apps/internal-ui/src/features/employees/EmployeeTable.tsx index e4dd83c..87368b2 100644 --- a/apps/internal-ui/src/features/employees/EmployeeTable.tsx +++ b/apps/internal-ui/src/features/employees/EmployeeTable.tsx @@ -48,7 +48,7 @@ function EmployeeRow({ employee, block, openEmployee, menuId, setMenuId }: { emp }; return ( -
+
{employee.department === "sales" ? "Продажи" : "—"} diff --git a/apps/internal-ui/src/features/employees/EmployeesFilters.tsx b/apps/internal-ui/src/features/employees/EmployeesFilters.tsx index 213117a..2ce07fd 100644 --- a/apps/internal-ui/src/features/employees/EmployeesFilters.tsx +++ b/apps/internal-ui/src/features/employees/EmployeesFilters.tsx @@ -25,7 +25,7 @@ export function EmployeesFilters({
Роль - +
Статус diff --git a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx index d27b3c1..e3a7978 100644 --- a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx +++ b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx @@ -35,7 +35,7 @@ export function EmployeesPage({ employees, reload, openEmployee }: { employees: Доступ к Hub · показано {filtered.length} из {employees.length}} - action={} + action={} /> void; openDialog: (conversationId: number) => void; }) { diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index 0ac41ae..108faa4 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -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 (
{showSupportSidebar ? : showSalesSidebar ? : } diff --git a/apps/internal-ui/src/shared/table-controls.css b/apps/internal-ui/src/shared/table-controls.css index dc24c9a..1e3b4af 100644 --- a/apps/internal-ui/src/shared/table-controls.css +++ b/apps/internal-ui/src/shared/table-controls.css @@ -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; diff --git a/apps/internal-ui/src/shared/ui.tsx b/apps/internal-ui/src/shared/ui.tsx index 47ae810..2cc5835 100644 --- a/apps/internal-ui/src/shared/ui.tsx +++ b/apps/internal-ui/src/shared/ui.tsx @@ -32,8 +32,14 @@ export function StatusPill({ status }: { status: "normal" | "active" | "blocked" return {label}; } +const ROLE_LABELS: Record = { + OWNER: "Владелец", + ADMIN: "Администратор", + EMPLOYEE: "Сотрудник", +}; + export function RoleBadge({ role }: { role: Role }) { - return {role}; + return {ROLE_LABELS[role] ?? role}; } export function ProductTag({ product }: { product: Product }) { diff --git a/apps/internal-ui/src/types.ts b/apps/internal-ui/src/types.ts index 7d61783..1252b9b 100644 --- a/apps/internal-ui/src/types.ts +++ b/apps/internal-ui/src/types.ts @@ -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; diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts index 4e8d8ea..a0ee1e9 100644 --- a/tests/e2e/internal-ui.spec.ts +++ b/tests/e2e/internal-ui.spec.ts @@ -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: [] } }));