feat(department-roles): complete LDAP architecture redesign with RoleAssignment model

BREAKING CHANGES:
- Removed OU=Roles from LDAP structure, role groups now created directly in department OU
- Role groups use ROLE_<Name> naming convention (similar to DEP_, POS_)

New features:
- RoleAssignment model: allows assigning roles to ANY employee, not just department members
- New API endpoints: /assign/, /revoke/, /assignments/ on DepartmentRoleViewSet
- Permission system updated: has_dept_perm() checks RoleAssignment first
- Employee profile shows external roles with 'внешняя роль' badge
- Employee list filter includes role-only employees with department_relation field
- Role assignment modal now searches all employees via AJAX

LDAP changes:
- _ensure_role_group(): creates ROLE_<Name> group in department OU
- assign_role(), revoke_role(): manage RoleAssignment + LDAP sync
- rename_role_group(), delete_role_group(): full lifecycle support
- Removed all references to OU=Roles

Migrations:
- 0035: Create RoleAssignment model
- 0036: Data migration from EmployeeDepartment.role

Tests:
- 7 new tests for role assignment (all passing)

Docs:
- 9 detailed reports on architecture, models, API, LDAP, tests
This commit is contained in:
Игорь Надеин 2025-12-30 10:35:34 +03:00
parent 5e0e058b3a
commit fff16130f3
23 changed files with 7136 additions and 131 deletions

View File

@ -360,14 +360,28 @@ class EmployeeSerializer(serializers.ModelSerializer):
return instance
def get_departments(self, obj):
"""
Возвращает список отделов сотрудника.
Включает:
1. Отделы через EmployeeDepartment (членство)
2. Отделы через RoleAssignment (роли без членства) — помечены via_assignment=True
"""
from employees.models import RoleAssignment
# 1. Отделы через членство (EmployeeDepartment)
links = getattr(obj, "dept_links", None)
if links is None:
links = EmployeeDepartment.objects.select_related(
"department", "role"
).filter(employee=obj, is_active=True)
out = []
member_dept_ids = set()
for link in links:
dept = link.department
member_dept_ids.add(link.department_id)
out.append(
{
"id": link.department_id,
@ -375,8 +389,38 @@ class EmployeeSerializer(serializers.ModelSerializer):
"role_id": link.role_id,
"role_name": link.role.name if link.role_id else None,
"is_head": (dept.head_id == obj.id),
"via_assignment": False, # через членство
}
)
# 2. Роли через RoleAssignment (без членства в отделе)
role_assignments = RoleAssignment.objects.select_related(
"role__department"
).filter(employee=obj, is_active=True)
for ra in role_assignments:
dept = ra.role.department
# Пропускаем отделы, где сотрудник уже член
if dept.id in member_dept_ids:
continue
# Проверяем, не добавлен ли уже этот отдел через другую роль
existing = next((d for d in out if d["id"] == dept.id and d.get("via_assignment")), None)
if existing:
# Добавляем роль к существующей записи (несколько ролей в одном отделе)
existing["role_name"] = f"{existing['role_name']}, {ra.role.name}" if existing.get("role_name") else ra.role.name
else:
out.append(
{
"id": dept.id,
"name": dept.name,
"role_id": ra.role_id,
"role_name": ra.role.name,
"is_head": False,
"via_assignment": True, # через RoleAssignment (не член отдела)
}
)
return out
def get_fields(self):
@ -512,6 +556,8 @@ class EmployeeListSerializer(serializers.ModelSerializer):
departments = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
skills = SkillSerializer(many=True, read_only=True)
# Поля для фильтра по отделу — информация о типе связи
department_relation = serializers.SerializerMethodField()
class Meta:
model = Employee
@ -529,8 +575,51 @@ class EmployeeListSerializer(serializers.ModelSerializer):
"skills",
"created_at",
"display_name",
"department_relation",
)
def get_department_relation(self, obj):
"""
Возвращает информацию о типе связи сотрудника с отфильтрованным отделом.
Поля:
- is_member: True если член отдела через EmployeeDepartment
- is_head: True если руководитель отдела
- has_role_only: True если только роль через RoleAssignment (не член)
- role_names: список названий ролей через RoleAssignment
Возвращает None если не применён фильтр по отделу.
"""
# Проверяем аннотации от фильтра по отделу
is_member = getattr(obj, "_is_dept_member", None)
is_head = getattr(obj, "_is_dept_head", None)
has_role = getattr(obj, "_has_role_assignment", None)
# Если аннотаций нет — фильтр по отделу не применён
if is_member is None:
return None
# Получаем названия ролей через RoleAssignment
role_names = []
request = self.context.get("request")
dept_id = getattr(request, "_department_filter_id", None) if request else None
if dept_id and has_role:
from employees.models import RoleAssignment
assignments = RoleAssignment.objects.filter(
employee=obj,
role__department_id=dept_id,
is_active=True
).select_related("role")
role_names = [a.role.name for a in assignments]
return {
"is_member": bool(is_member),
"is_head": bool(is_head),
"has_role_only": bool(has_role) and not bool(is_member),
"role_names": role_names,
}
def get_departments(self, obj):
links = getattr(obj, "dept_links", None)
if links is None:

View File

@ -28,7 +28,7 @@ from employees.ldap.errors import (DirectoryDbError, DirectoryLdapError,
from employees.ldap.infrastructure.connections import _conn
from employees.models import (Department, DepartmentPermission, DepartmentRole,
DeptPerm, EmployeeAction, EmployeeDepartment,
LdapSyncState, Position, Skill)
LdapSyncState, Position, RoleAssignment, Skill)
from employees.utils import (_build_links_for_dept, _detect_phone_field,
_ensure_department_permissions,
_head_choices_for_dept, _normalize_phone,
@ -955,16 +955,18 @@ class DepartmentViewSet(viewsets.ModelViewSet):
@transaction.atomic
def set_member_role(self, request, pk=None):
"""
Назначает/снимает РОЛЬ существующему участнику отдела.
Не создаёт членство и не меняет is_active.
Назначает/снимает РОЛЬ сотруднику в контексте отдела.
Если сотрудник — член отдела, обновляет EmployeeDepartment.role.
Если нет — создаёт/обновляет RoleAssignment (новая логика: роли можно
назначать любому сотруднику компании).
Тело:
{ "employee_id": <int>, "role_id": <int|null> }
Права:
AssignRolePerm (DeptPerm.ASSIGN_ROLE)
Ответ:
200 {"employee_id":..., "role_id": <int|null>, "is_active": <bool>}
404 если сотрудник не состоит в отделе
200 {"employee_id":..., "role_id": <int|null>, "is_active": <bool>, "via_assignment": <bool>}
400 если роль не принадлежит отделу
"""
dept = self.get_object()
@ -987,47 +989,85 @@ class DepartmentViewSet(viewsets.ModelViewSet):
status=400,
)
svc = DirectoryService() if _is_ldap_enabled() else None
# Проверяем, является ли сотрудник членом отдела
link = EmployeeDepartment.objects.filter(
employee_id=emp_id, department_id=dept.id
).first()
if svc:
# Режим с LDAP: синхронизируем роли
try:
# внутри: проверяет наличие линка, меняет роль, (опц.) LDAP-группу
svc.set_member_role(dept, employee, role)
link = EmployeeDepartment.objects.get(
employee_id=emp_id, department_id=dept.id
)
except EmployeeDepartment.DoesNotExist:
return Response(
{"detail": "Employee is not a member of this department."}, status=404
)
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
return Response(
{"detail": str(e)},
status=(
502
if isinstance(e, DirectoryLdapError)
else 400 if isinstance(e, DirectoryServiceError) else 500
),
)
else:
# Режим без LDAP: просто обновляем роль в линке
try:
link = EmployeeDepartment.objects.get(
employee_id=emp_id, department_id=dept.id
)
via_assignment = False
if link:
# Сотрудник — член отдела: обновляем роль в линке (старая логика)
svc = DirectoryService() if _is_ldap_enabled() else None
if svc:
try:
svc.set_member_role(dept, employee, role)
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
return Response(
{"detail": str(e)},
status=(
502
if isinstance(e, DirectoryLdapError)
else 400 if isinstance(e, DirectoryServiceError) else 500
),
)
else:
link.role = role
link.save(update_fields=["role"])
except EmployeeDepartment.DoesNotExist:
return Response(
{"detail": "Employee is not a member of this department."}, status=404
# Также создаём/обновляем RoleAssignment для консистентности
if role:
RoleAssignment.objects.update_or_create(
employee_id=emp_id,
role=role,
defaults={"is_active": True, "assigned_by": request.user}
)
else:
# Снятие роли — деактивируем все назначения этой роли для сотрудника
RoleAssignment.objects.filter(
employee_id=emp_id,
role__department=dept,
is_active=True
).update(is_active=False)
else:
# Сотрудник НЕ член отдела: используем только RoleAssignment
via_assignment = True
if role:
# Назначаем роль через RoleAssignment
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
if _is_ldap_enabled():
try:
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
dept_service.assign_role(employee, role, request.user)
except Exception as e:
return Response({"detail": str(e)}, status=400)
else:
RoleAssignment.objects.update_or_create(
employee=employee,
role=role,
defaults={"is_active": True, "assigned_by": request.user}
)
else:
# Снятие всех ролей отдела для этого сотрудника
RoleAssignment.objects.filter(
employee_id=emp_id,
role__department=dept,
is_active=True
).update(is_active=False)
return Response(
{
"employee_id": emp_id,
"role_id": (role.id if role else None),
"is_active": link.is_active,
"is_active": link.is_active if link else True,
"via_assignment": via_assignment,
},
status=status.HTTP_200_OK,
)
@ -1443,7 +1483,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
qp = self.request.query_params
# по отделу: связи + руководитель
# по отделу: связи + руководитель + сотрудники с ролями через RoleAssignment
dep = qp.get("department")
if dep:
try:
@ -1451,11 +1491,49 @@ class EmployeeViewSet(viewsets.ModelViewSet):
except (TypeError, ValueError):
dep_id = None
if dep_id:
# Члены отдела
member_ids = EmployeeDepartment.objects.filter(
department_id=dep_id
).values("employee_id")
# Руководитель
head_ids = Department.objects.filter(id=dep_id).values("head_id")
qs = qs.filter(Q(id__in=member_ids) | Q(id__in=head_ids))
# Сотрудники с ролями через RoleAssignment (не члены отдела)
role_assignment_ids = RoleAssignment.objects.filter(
role__department_id=dep_id,
is_active=True
).values("employee_id")
qs = qs.filter(
Q(id__in=member_ids) | Q(id__in=head_ids) | Q(id__in=role_assignment_ids)
).distinct()
# Аннотируем тип связи с отделом для каждого сотрудника
# is_dept_member: True если член отдела, False если только роль
qs = qs.annotate(
_is_dept_member=Exists(
EmployeeDepartment.objects.filter(
employee_id=OuterRef("pk"),
department_id=dep_id,
is_active=True
)
),
_is_dept_head=Exists(
Department.objects.filter(
id=dep_id,
head_id=OuterRef("pk")
)
),
_has_role_assignment=Exists(
RoleAssignment.objects.filter(
employee_id=OuterRef("pk"),
role__department_id=dep_id,
is_active=True
)
)
)
# Сохраняем dep_id в request для использования в сериализаторе
self.request._department_filter_id = dep_id
# по должности
position = qp.get("position")
@ -2598,6 +2676,8 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
"partial_update",
"destroy",
"set_perms",
"assign",
"revoke",
}:
return [self.AssignRolePerm()]
return [IsAuthenticated()]
@ -2678,6 +2758,131 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
ser = self.get_serializer(role)
return Response(ser.data, status=200)
@action(detail=True, methods=["get"])
def assignments(self, request, pk=None):
"""
Возвращает список назначений роли (RoleAssignment).
Query params:
- ?active=true/false — фильтр по is_active (по умолчанию только активные)
"""
role = self.get_object()
qs = RoleAssignment.objects.filter(role=role).select_related(
"employee", "assigned_by"
)
active = request.query_params.get("active", "true").lower()
if active == "true":
qs = qs.filter(is_active=True)
elif active == "false":
qs = qs.filter(is_active=False)
data = [
{
"id": a.id,
"employee_id": a.employee_id,
"employee_name": str(a.employee) if a.employee else None,
"assigned_at": a.assigned_at.isoformat() if a.assigned_at else None,
"assigned_by_id": a.assigned_by_id,
"assigned_by_name": str(a.assigned_by) if a.assigned_by else None,
"is_active": a.is_active,
}
for a in qs.order_by("-assigned_at")
]
return Response({"count": len(data), "results": data}, status=200)
@action(detail=True, methods=["post"])
def assign(self, request, pk=None):
"""
Назначает роль сотруднику (не требует членства в отделе).
Тело запроса:
- employee_id: int — ID сотрудника
Требуется право DeptPerm.ASSIGN_ROLE в отделе роли.
"""
role = self.get_object()
employee_id = request.data.get("employee_id")
if not employee_id:
return Response(
{"detail": "employee_id is required."}, status=400
)
try:
employee = Employee.objects.get(id=employee_id)
except Employee.DoesNotExist:
return Response(
{"detail": "Сотрудник не найден."}, status=404
)
# Получаем assigned_by из текущего пользователя
# В этом проекте User == Employee (кастомная модель)
assigned_by = request.user if request.user.is_authenticated else None
# Используем DepartmentService для назначения с LDAP-синхронизацией
try:
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
assignment = dept_service.assign_role(employee, role, assigned_by)
return Response({
"id": assignment.id,
"employee_id": assignment.employee_id,
"role_id": assignment.role_id,
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
"is_active": assignment.is_active,
}, status=201)
except Exception as e:
return Response({"detail": str(e)}, status=400)
@action(detail=True, methods=["post"])
def revoke(self, request, pk=None):
"""
Отзывает роль у сотрудника.
Тело запроса:
- employee_id: int — ID сотрудника
Требуется право DeptPerm.ASSIGN_ROLE в отделе роли.
"""
role = self.get_object()
employee_id = request.data.get("employee_id")
if not employee_id:
return Response(
{"detail": "employee_id is required."}, status=400
)
try:
employee = Employee.objects.get(id=employee_id)
except Employee.DoesNotExist:
return Response(
{"detail": "Сотрудник не найден."}, status=404
)
# Используем DepartmentService для отзыва с LDAP-синхронизацией
try:
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
dept_service.revoke_role(employee, role)
return Response(status=204)
except Exception as e:
return Response({"detail": str(e)}, status=400)
class SkillViewSet(viewsets.ModelViewSet):
"""

View File

@ -6,7 +6,7 @@ from typing import Any, Dict, Iterable, Optional, Tuple, Type
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from django.db.models import Model
from employees.constants import DeptPerm
from employees.models import Department, EmployeeDepartment
from employees.models import Department, EmployeeDepartment, RoleAssignment
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
@ -36,8 +36,8 @@ def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
- Иначе: проверяет RoleAssignment — есть ли активное назначение роли
с нужным правом в данном отделе.
Args:
user (User): Текущий пользователь.
@ -64,12 +64,21 @@ def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
# Проверка через RoleAssignment (новая модель — не требует членства в отделе)
if RoleAssignment.objects.filter(
employee_id=user.id,
role__department_id=dept.id,
role__scoped_permissions__code=perm_code,
is_active=True,
).exists():
return True
# Fallback: старая проверка через EmployeeDepartment.role (обратная совместимость)
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
role__scoped_permissions__code=perm_code,
).exists()
@ -80,8 +89,9 @@ def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
3) пользователь имеет активное назначение роли (RoleAssignment) с правом `code` → True
4) fallback: старая проверка через EmployeeDepartment.role
5) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
@ -92,7 +102,16 @@ def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
# 3) Проверка через RoleAssignment (новая модель — не требует членства в отделе)
if RoleAssignment.objects.filter(
employee_id=user.id,
role__department_id=department_id,
role__scoped_permissions__code=code,
is_active=True,
).exists():
return True
# 4) Fallback: старая проверка через EmployeeDepartment.role
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
@ -101,7 +120,6 @@ def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()

View File

@ -0,0 +1,429 @@
# Отчет: Модели данных системы ролей отделов
**Дата**: 30.12.2025
**Анализ**: Структура моделей DepartmentRole, DepartmentPermission, EmployeeDepartment
---
## 1. DepartmentPermission — Справочник прав отдела
### Расположение
`backend/employees/models.py` (строки ~450-470)
### Структура модели
```python
class DepartmentPermission(models.Model):
code = models.CharField(max_length=100, unique=True, db_index=True)
name = models.CharField(max_length=200)
```
### Назначение
Справочник департаментских прав (scoped permissions), которые могут назначаться ролям внутри отделов.
### Поля
| Поле | Тип | Особенности | Назначение |
|------|-----|-------------|------------|
| `code` | CharField(100) | unique=True, db_index=True | Уникальный код права (например, "manage_department") |
| `name` | CharField(200) | - | Человекочитаемое название права |
### Источник кодов прав
Коды синхронизируются с `employees.constants.DeptPerm.CHOICES`:
```python
class DeptPerm:
MANAGE = "manage_department"
CHANGE_HEAD = "change_department_head"
ASSIGN_ROLE = "assign_department_role"
MANAGE_CALENDAR = "manage_department_events"
CREATE_POST = "publish_department_post"
MANAGE_FEED = "manage_department_feed"
VIEW_REQUESTCOMMENT = "view_requestcomment"
ADD_REQUESTCOMMENT = "add_requestcomment"
VIEW_REQUEST = "view_request"
CAN_PROCESS_REQUESTS = "can_process_requests"
CHOICES = (
(MANAGE, "Управлять отделом"),
(CHANGE_HEAD, "Назначать руководителя"),
(ASSIGN_ROLE, "Назначать роли участникам"),
(MANAGE_CALENDAR, "Управлять календарём отдела"),
(CREATE_POST, "Публиковать новости на странице отдела"),
(MANAGE_FEED, "Редактировать публикации отдела"),
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
(VIEW_REQUEST, "Просмотр заявлений отдела"),
(CAN_PROCESS_REQUESTS, "Рассмотрение заявлений отдела"),
)
```
### Идемпотентная синхронизация
Записи создаются/обновляются автоматически через `_ensure_department_permissions()` в `employees/utils.py`.
### Особенности
- **Единственная точка истины**: `DeptPerm.CHOICES` в constants.py
- **Автоматическая синхронизация**: При вызове `/api/v1/department-roles/perm_choices/` или `ui-context/`
- **Нет миграций**: Новые права добавляются через код без миграций БД
---
## 2. DepartmentRole — Роль внутри отдела
### Расположение
`backend/employees/models.py` (строки ~470-500)
### Структура модели
```python
class DepartmentRole(models.Model):
department = models.ForeignKey(
'Department',
on_delete=models.CASCADE,
related_name='roles'
)
name = models.CharField(max_length=100)
scoped_permissions = models.ManyToManyField(
DepartmentPermission,
blank=True,
related_name='roles'
)
ldap_group_dn = models.CharField(max_length=500, blank=True, default='')
class Meta:
unique_together = [('department', 'name')]
verbose_name = 'Роль отдела'
verbose_name_plural = 'Роли отделов'
```
### Назначение
Роль, определённая в рамках конкретного отдела. Каждая роль имеет набор скоуп-прав (scoped_permissions), действующих только в этом отделе.
### Поля
| Поле | Тип | Связь | Особенности | Назначение |
|------|-----|-------|-------------|------------|
| `department` | ForeignKey | Department | CASCADE, related_name='roles' | Отдел, к которому принадлежит роль |
| `name` | CharField(100) | - | unique_together с department | Название роли (уникально в рамках отдела) |
| `scoped_permissions` | ManyToManyField | DepartmentPermission | blank=True, related_name='roles' | Набор прав роли |
| `ldap_group_dn` | CharField(500) | - | blank=True, default='' | DN группы в LDAP (для синхронизации) |
### Constraints
```python
unique_together = [('department', 'name')]
```
**Значение**: Имя роли уникально только в рамках одного отдела. В разных отделах могут быть роли с одинаковыми именами.
### Связи
```python
Department.roles # related_name='roles'
# Получить все роли отдела:
department.roles.all()
```
### LDAP Integration
- **OU=Roles внутри OU отдела**: Для каждой роли создаётся LDAP-группа внутри `OU=Roles,OU=<Отдел>,OU=Departments,...`
- **Автоматическая синхронизация**: При назначении роли пользователю (`set_member_role`) происходит добавление в соответствующую LDAP-группу
- **Хранение DN**: Поле `ldap_group_dn` содержит полный DN группы роли
---
## 3. EmployeeDepartment — Связь сотрудника и отдела
### Расположение
`backend/employees/models.py` (строки ~500-540)
### Структура модели
```python
class EmployeeDepartment(models.Model):
employee = models.ForeignKey(
'Employee',
on_delete=models.CASCADE,
related_name='department_memberships'
)
department = models.ForeignKey(
'Department',
on_delete=models.CASCADE,
related_name='employee_links'
)
role = models.ForeignKey(
DepartmentRole,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='employee_assignments'
)
date_from = models.DateField(blank=True, null=True)
date_to = models.DateField(blank=True, null=True)
is_active = models.BooleanField(default=True, db_index=True)
class Meta:
unique_together = [('department', 'employee')]
verbose_name = 'Членство в отделе'
verbose_name_plural = 'Членство в отделах'
```
### Назначение
Связывает сотрудника с отделом и **опционально** назначает роль. Поддерживает временные рамки членства и флаг активности.
### Поля
| Поле | Тип | Связь | Особенности | Назначение |
|------|-----|-------|-------------|------------|
| `employee` | ForeignKey | Employee | CASCADE, related_name='department_memberships' | Сотрудник |
| `department` | ForeignKey | Department | CASCADE, related_name='employee_links' | Отдел |
| `role` | ForeignKey | DepartmentRole | SET_NULL, null=True, blank=True, related_name='employee_assignments' | Роль в отделе (опционально) |
| `date_from` | DateField | - | null=True, blank=True | Дата начала членства |
| `date_to` | DateField | - | null=True, blank=True | Дата окончания членства |
| `is_active` | BooleanField | - | default=True, db_index=True | Флаг активного членства |
### Constraints
```python
unique_together = [('department', 'employee')]
```
**Значение**: Сотрудник может иметь только одну связь с каждым отделом. Нет дублирования членства.
### Ключевые особенности
#### 1. Роль опциональна
```python
role = models.ForeignKey(..., null=True, blank=True)
```
- Сотрудник может быть членом отдела **без роли**
- Роль назначается отдельно через `set_member_role()`
- Разделение: `add_member` (добавить в отдел) != `set_member_role` (назначить роль)
#### 2. SET_NULL при удалении роли
```python
role = models.ForeignKey(..., on_delete=models.SET_NULL)
```
- При удалении роли у сотрудников автоматически `role = None`
- Членство в отделе сохраняется
- Не требуется вручную очищать связи
#### 3. is_active флаг
- **Indexed**: `db_index=True` для быстрых фильтраций
- **Использование**: Проверка прав в `has_dept_perm()` учитывает только `is_active=True`
- **Soft delete**: Можно деактивировать членство без физического удаления
### Связи
```python
# Получить все отделы сотрудника:
employee.department_memberships.filter(is_active=True)
# Получить всех сотрудников отдела:
department.employee_links.filter(is_active=True).select_related('employee', 'role')
# Получить всех сотрудников с конкретной ролью:
role.employee_assignments.filter(is_active=True)
```
---
## 4. Department.Meta.permissions — Django-уровень
### Расположение
`backend/employees/models.py` — класс Department
### Структура
```python
class Department(models.Model):
# ... поля ...
class Meta:
permissions = [
("change_department_head", "Может назначать руководителя отдела"),
("assign_department_role", "Может назначать роли участникам отдела"),
("manage_department", "Может управлять отделом"),
]
```
### Назначение
Дополнительные Django permissions на уровне модели Department. Используются в системе прав совместно со scoped permissions.
### Взаимодействие уровней прав
```
┌──────────────────────────────────────┐
│ 1. Django Model Permissions │
│ - change_department_head │
│ - assign_department_role │
│ - manage_department │
└──────────────────────────────────────┘
↓
┌──────────────────────────────────────┐
│ 2. DepartmentPermission (scoped) │
│ - Записи в БД из DeptPerm.CHOICES │
│ - Привязываются к ролям │
└──────────────────────────────────────┘
↓
┌──────────────────────────────────────┐
│ 3. DepartmentRole.scoped_permissions │
│ - M2M к DepartmentPermission │
│ - Действует только в своём отделе │
└──────────────────────────────────────┘
↓
┌──────────────────────────────────────┐
│ 4. EmployeeDepartment.role │
│ - Связь сотрудника с ролью │
│ - Проверка: is_active=True │
└──────────────────────────────────────┘
```
---
## 5. Критические особенности архитектуры
### 5.1 Двухуровневая система прав
**Уровень 1: Django permissions** (глобальные)
- Привязаны к модели Department
- Проверяются через `user.has_perm('employees.manage_department')`
- **НЕ учитывают** конкретный отдел
**Уровень 2: Scoped permissions** (в рамках отдела)
- Хранятся в `DepartmentPermission`
- Назначаются ролям через `DepartmentRole.scoped_permissions`
- Проверяются через `has_dept_perm(user, dept_id, code)`
- **Учитывают** конкретный отдел
### 5.2 Руководитель отдела имеет все права
```python
# В has_dept_perm():
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True # ✅ Руководитель имеет любое право
```
**Значение**: Руководителю не нужны роли для управления своим отделом.
### 5.3 Разделение операций
**add_member** (`DeptPerm.MANAGE`)
- Добавляет сотрудника в отдел
- **НЕ назначает роль** (`role=None`)
- Требует право `manage_department`
**set_member_role** (`DeptPerm.ASSIGN_ROLE`)
- Назначает/снимает роль у **существующего** члена
- Требует членство в отделе
- Требует право `assign_department_role`
**remove_member** (`DeptPerm.MANAGE`)
- Удаляет из отдела (deactivate или delete)
- Автоматически очищает роль (SET_NULL)
### 5.4 Уникальность и скоуп
```python
# ✅ Правильно: разные отделы, одно имя роли
DepartmentRole(department=dept1, name="Manager")
DepartmentRole(department=dept2, name="Manager")
# ❌ Ошибка: один отдел, одно имя
DepartmentRole(department=dept1, name="Manager")
DepartmentRole(department=dept1, name="Manager") # IntegrityError
```
---
## 6. Проблемные места и ограничения
### 6.1 Смешение уровней прав
**Проблема**: Django permissions в Department.Meta и scoped permissions в DepartmentPermission имеют частичное пересечение кодов.
**Пример**:
- `Department.Meta.permissions`: `"manage_department"`
- `DeptPerm.MANAGE`: `"manage_department"`
**Следствие**: Неочевидно, какой уровень проверять в каком контексте.
### 6.2 Автоматическая синхронизация permissions
**Проблема**: `_ensure_department_permissions()` создаёт записи при каждом вызове API.
**Риски**:
- Нет контроля версий прав
- Удаление кода из `DeptPerm.CHOICES` не удаляет запись из БД
- Нет миграций для отслеживания изменений
### 6.3 LDAP soft dependency
**Проблема**: Код везде проверяет `if _is_ldap_enabled()` для переключения логики.
**Следствие**: Две разные кодовые ветки, усложнение тестирования.
### 6.4 Нет валидации role.department == link.department
**Проблема**: При назначении роли не проверяется на уровне модели, что роль принадлежит тому же отделу.
**Защита**: Только в API-слое (`set_member_role` в views.py).
---
## 7. Рекомендации по рефакторингу
### 7.1 Унифицировать систему прав
- Удалить Django permissions из `Department.Meta`
- Оставить только scoped permissions через `DepartmentPermission`
- Добавить миграцию для перехода
### 7.2 Добавить валидацию на уровне модели
```python
class EmployeeDepartment(models.Model):
def clean(self):
if self.role and self.role.department_id != self.department_id:
raise ValidationError("Role must belong to the same department")
```
### 7.3 Создать миграции для permissions
- Вместо `_ensure_department_permissions()` использовать data migrations
- Добавить версионирование справочника прав
### 7.4 Абстрагировать LDAP-логику
- Создать адаптер `DepartmentRoleBackend` с двумя реализациями: LDAP и DB-only
- Убрать `if _is_ldap_enabled()` из бизнес-логики
---
## 8. Итоговая схема данных
```
┌──────────────────────────────────┐
│ Department │
│ - id │
│ - name │
│ - head_id (FK → Employee) │
│ - ldap_group_dn │
└──────────────────────────────────┘
↑ (department FK)
│
┌──────────────────────────────────┐ ┌──────────────────────────────┐
│ DepartmentRole │ M2M │ DepartmentPermission │
│ - id │←────────→ - id │
│ - department_id (FK ↑) │ │ - code (unique) │
│ - name (unique per dept) │ │ - name │
│ - ldap_group_dn │ └──────────────────────────────┘
└──────────────────────────────────┘
↑ (role FK, nullable)
│
┌──────────────────────────────────┐
│ EmployeeDepartment │
│ - id │
│ - employee_id (FK → Employee) │
│ - department_id (FK ↑) │
│ - role_id (FK ↑, nullable) │
│ - is_active (indexed) │
│ - date_from, date_to │
│ UNIQUE: (department, employee) │
└──────────────────────────────────┘
```
---
**Следующий отчет**: [02_DEPARTMENT_ROLES_API.md](./02_DEPARTMENT_ROLES_API.md) — API ViewSet и endpoints

View File

@ -0,0 +1,572 @@
# Отчет: API ViewSet для работы с ролями отделов
**Дата**: 30.12.2025
**Анализ**: DepartmentRoleViewSet — CRUD операции, custom actions, система прав
---
## 1. Обзор ViewSet
### Расположение
`backend/api/v1/employees/views.py` (строки 2567-2660)
### Базовая информация
```python
class DepartmentRoleViewSet(viewsets.ModelViewSet):
queryset = (
DepartmentRole.objects.select_related("department")
.prefetch_related("scoped_permissions")
.all()
)
serializer_class = DepartmentRoleSerializer
ordering_fields = ("name", "id")
ordering = ("name", "id")
```
### URL Patterns
**Router registration**:
```python
router.register(r'department-roles', DepartmentRoleViewSet, basename='department-roles')
```
**Generated URLs**:
- `GET /api/v1/department-roles/` — list (с фильтрацией `?department=<id>`)
- `POST /api/v1/department-roles/` — create
- `GET /api/v1/department-roles/{id}/` — retrieve
- `PATCH/PUT /api/v1/department-roles/{id}/` — update/partial_update
- `DELETE /api/v1/department-roles/{id}/` — destroy
- `GET /api/v1/department-roles/perm_choices/` — список всех доступных прав
- `GET /api/v1/department-roles/{id}/perms/` — права конкретной роли
- `POST /api/v1/department-roles/{id}/set_perms/` — установка прав роли
---
## 2. Стандартные CRUD операции
### 2.1 LIST — Получение списка ролей
**Endpoint**: `GET /api/v1/department-roles/`
**Query params**:
- `department` (int) — фильтр по ID отдела
- `ordering` (str) — сортировка: `name`, `-name`, `id`, `-id`
**Permission**: `IsAuthenticated()` — любой авторизованный пользователь
**Логика**:
```python
def get_queryset(self):
qs = super().get_queryset()
dept = self.request.query_params.get("department")
if dept:
qs = qs.filter(department_id=dept)
# Стабильная сортировка с tie-break по id
ord_param = self.request.query_params.get("ordering")
if ord_param in {"name", "-name", "id", "-id"}:
qs = qs.order_by(
ord_param, "id" if not ord_param.startswith("-") else "-id"
)
else:
qs = qs.order_by(*self.ordering) # по умолчанию: name, id
return qs
```
**Особенности**:
- **Prefetch optimization**: `select_related("department")` + `prefetch_related("scoped_permissions")`
- **Стабильная сортировка**: Всегда добавляется tie-break по `id` для однозначного порядка
- **Без пагинации по умолчанию**: Если не указана в settings
**Пример запроса**:
```bash
GET /api/v1/department-roles/?department=5&ordering=name
```
**Пример ответа**:
```json
{
"count": 3,
"results": [
{
"id": 12,
"department": 5,
"name": "Engineer",
"scoped_permissions": [1, 3],
"permissions": [1, 3],
"permissions_verbose": [
{"id": 1, "code": "manage_department", "name": "Управлять отделом"},
{"id": 3, "code": "assign_department_role", "name": "Назначать роли"}
]
}
]
}
```
---
### 2.2 RETRIEVE — Получение одной роли
**Endpoint**: `GET /api/v1/department-roles/{id}/`
**Permission**: `IsAuthenticated()`
**Ответ**: Полная информация о роли с backward-compat полями.
---
### 2.3 CREATE — Создание роли
**Endpoint**: `POST /api/v1/department-roles/`
**Permission**: `AssignRolePerm()` — требует `DeptPerm.ASSIGN_ROLE` в целевом отделе
```python
class AssignRolePerm(AdminOrDeptAllowed):
required_code = DeptPerm.ASSIGN_ROLE
def get_permissions(self):
if self.action in {"create", "update", "partial_update", "destroy", "set_perms"}:
return [self.AssignRolePerm()]
return [IsAuthenticated()]
```
**Body**:
```json
{
"department": 5,
"name": "Manager",
"scoped_permissions": [1, 3] // или
"scoped_permission_codes": ["manage_department", "assign_department_role"]
}
```
**Логика проверки прав**:
1. Извлекается `department` из request.data
2. `AdminOrDeptAllowed` проверяет:
- Пользователь staff/superuser → ✅
- Пользователь — руководитель отдела → ✅
- У пользователя есть роль с `assign_department_role` в этом отделе → ✅
- Иначе → 403
**Response**: `201 Created` + данные созданной роли
**Errors**:
- `400` — валидация (duplicate name в отделе, невалидные коды прав)
- `403` — нет прав
---
### 2.4 UPDATE/PARTIAL_UPDATE — Изменение роли
**Endpoints**:
- `PUT /api/v1/department-roles/{id}/`
- `PATCH /api/v1/department-roles/{id}/`
**Permission**: `AssignRolePerm()` — требует право в **отделе роли** (не в теле запроса)
**Body** (PATCH):
```json
{
"name": "Senior Manager",
"scoped_permission_codes": ["manage_department", "change_department_head"]
}
```
**Особенность**:
- Права проверяются по `role.department_id` (из БД)
- Нельзя изменить роль отдела A, имея права только в отделе B
**Response**: `200 OK` + обновлённые данные
---
### 2.5 DESTROY — Удаление роли
**Endpoint**: `DELETE /api/v1/department-roles/{id}/`
**Permission**: `AssignRolePerm()` в отделе роли
**Логика**:
1. Проверка прав в отделе роли
2. Каскадное удаление роли
3. `EmployeeDepartment.role` → автоматически `SET_NULL` (on_delete=SET_NULL)
4. LDAP-группа роли остаётся (нет автоматической очистки)
**Response**: `204 No Content` или `200 OK`
**Последствия**:
- Все сотрудники с этой ролью теряют её (`role=None`)
- Членство в отделе сохраняется
- Права автоматически удаляются (M2M cascade)
---
## 3. Custom Actions
### 3.1 perm_choices — Справочник доступных прав
**Endpoint**: `GET /api/v1/department-roles/perm_choices/`
**Permission**: `IsAuthenticated()`
**Назначение**: Возвращает полный список прав, которые можно назначить ролям.
**Логика**:
```python
@action(detail=False, methods=["get"])
def perm_choices(self, request):
data = _ensure_department_permissions() # Создаёт/синхронизирует из DeptPerm.CHOICES
return Response({"count": len(data), "results": data}, status=200)
```
**Response**:
```json
{
"count": 10,
"results": [
{"id": 1, "code": "manage_department", "name": "Управлять отделом"},
{"id": 2, "code": "change_department_head", "name": "Назначать руководителя"},
{"id": 3, "code": "assign_department_role", "name": "Назначать роли участникам"},
...
]
}
```
**Особенность**: Идемпотентно создаёт записи в `DepartmentPermission` из `DeptPerm.CHOICES`.
---
### 3.2 perms — Права конкретной роли
**Endpoint**: `GET /api/v1/department-roles/{id}/perms/`
**Permission**: `IsAuthenticated()`
**Назначение**: Получить список прав, назначенных данной роли.
**Логика**:
```python
@action(detail=True, methods=["get"])
def perms(self, request, pk=None):
role = self.get_object()
data = [
{"id": p.id, "code": p.code, "name": p.name}
for p in role.scoped_permissions.order_by("code")
]
return Response({"count": len(data), "results": data}, status=200)
```
**Response**:
```json
{
"count": 2,
"results": [
{"id": 3, "code": "assign_department_role", "name": "Назначать роли участникам"},
{"id": 1, "code": "manage_department", "name": "Управлять отделом"}
]
}
```
**Сортировка**: Права отсортированы по `code` (алфавитный порядок).
---
### 3.3 set_perms — Установка прав роли
**Endpoint**: `POST /api/v1/department-roles/{id}/set_perms/`
**Permission**: `AssignRolePerm()` в отделе роли
**Назначение**: Полная замена набора прав у роли.
**Body** (один из вариантов):
```json
{
"permission_ids": [1, 3, 5]
}
```
или
```json
{
"permission_codes": ["manage_department", "assign_department_role", "view_request"]
}
```
**Логика**:
```python
@action(detail=True, methods=["post"])
def set_perms(self, request, pk=None):
role = self.get_object()
ids = request.data.get("permission_ids") or []
codes = request.data.get("permission_codes") or []
if isinstance(ids, list) and ids:
ids_int = {int(i) for i in ids if str(i).isdigit()}
qs = DepartmentPermission.objects.filter(id__in=ids_int)
if qs.count() != len(ids_int):
return Response({"detail": "Некоторые permission_ids не найдены."}, status=400)
elif isinstance(codes, list) and codes:
codes_set = set(codes)
qs = DepartmentPermission.objects.filter(code__in=codes_set)
if qs.count() != len(codes_set):
return Response({"detail": "Некоторые permission_codes не найдены."}, status=400)
else:
qs = DepartmentPermission.objects.none()
role.scoped_permissions.set(list(qs)) # Полная замена
ser = self.get_serializer(role)
return Response(ser.data, status=200)
```
**Особенности**:
- **Полная замена**: `role.scoped_permissions.set()` — удаляет старые, добавляет новые
- **Валидация**: Проверяет существование всех указанных ID/кодов
- **Приоритет**: Если указаны оба (`ids` и `codes`), используются `ids`
**Response**: `200 OK` + полные данные роли с новым набором прав
**Errors**:
- `400` — некоторые permission_ids/codes не найдены
- `403` — нет права `assign_department_role` в отделе роли
---
## 4. Система прав ViewSet
### 4.1 Матрица прав по действиям
| Action | Permission Class | Required Right | Scope Check |
|--------|-----------------|----------------|-------------|
| `list` | `IsAuthenticated()` | — | Нет |
| `retrieve` | `IsAuthenticated()` | — | Нет |
| `create` | `AssignRolePerm()` | `assign_department_role` | По `request.data['department']` |
| `update` | `AssignRolePerm()` | `assign_department_role` | По `role.department_id` |
| `partial_update` | `AssignRolePerm()` | `assign_department_role` | По `role.department_id` |
| `destroy` | `AssignRolePerm()` | `assign_department_role` | По `role.department_id` |
| `perm_choices` | `IsAuthenticated()` | — | Нет |
| `perms` | `IsAuthenticated()` | — | Нет |
| `set_perms` | `AssignRolePerm()` | `assign_department_role` | По `role.department_id` |
### 4.2 Логика AdminOrDeptAllowed
```python
class AssignRolePerm(AdminOrDeptAllowed):
required_code = DeptPerm.ASSIGN_ROLE # "assign_department_role"
```
**Проверка прав** (порядок):
1. **Staff/Superuser**: `user.is_staff` или `user.is_superuser` → ✅ всегда
2. **Руководитель отдела**: `Department.objects.filter(id=dept_id, head_id=user.id).exists()` → ✅
3. **Роль с правом**:
```python
EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept_id,
is_active=True,
role__scoped_permissions__code="assign_department_role"
).exists()
```
→ ✅
**Извлечение department_id**:
- **CREATE**: Из `request.data['department']` или `request.data['department_id']`
- **UPDATE/DESTROY**: Из `role.department_id` (объект уже загружен)
---
## 5. Оптимизация запросов
### 5.1 Prefetch в queryset
```python
queryset = (
DepartmentRole.objects.select_related("department")
.prefetch_related("scoped_permissions")
.all()
)
```
**Цель**: Избежать N+1 queries при сериализации списка ролей.
**Результат**:
- 1 запрос для ролей
- 1 запрос для отделов (JOIN)
- 1 запрос для всех M2M связей с permissions
### 5.2 Сортировка
```python
ordering = ("name", "id") # Tie-break по id для детерминированного порядка
```
**Особенность**: При одинаковых `name` порядок стабилен благодаря добавлению `id`.
---
## 6. Связь с Department endpoints
### 6.1 Department.set_member_role()
**Endpoint**: `POST /api/v1/departments/{id}/set_member_role/`
**Permission**: `AssignRolePerm()` в этом отделе
**Body**:
```json
{
"employee_id": 42,
"role_id": 12 // или null для снятия роли
}
```
**Логика**:
1. Проверка существования `EmployeeDepartment` (сотрудник должен быть в отделе)
2. Валидация `role.department_id == dept.id`
3. Обновление `link.role = role`
4. Если LDAP включён: синхронизация через `DirectoryService.set_member_role()`
**Отличие от DepartmentRoleViewSet**:
- `DepartmentRoleViewSet` управляет **определениями ролей** (роли как сущности)
- `Department.set_member_role()` **назначает роли сотрудникам** (привязка)
---
## 7. Проблемные места
### 7.1 Отсутствие atomic транзакций
**Проблема**: CRUD операции не обёрнуты в `@transaction.atomic`.
**Риск**: При падении между удалением старых и добавлением новых прав в `set_perms()` можно получить inconsistent state.
**Рекомендация**: Добавить `@transaction.atomic` на `create`, `update`, `destroy`, `set_perms`.
---
### 7.2 Нет защиты от изменения department
**Проблема**: Через PATCH можно попытаться изменить `department` у существующей роли.
**Последствие**: Роль может стать несогласованной с уже назначенными сотрудниками.
**Текущая защита**: Serializer не включает `department` в `read_only_fields`, но валидация зависит от БД constraint.
**Рекомендация**: Сделать `department` read-only при update:
```python
def get_serializer(self, *args, **kwargs):
serializer = super().get_serializer(*args, **kwargs)
if self.action in {'update', 'partial_update'}:
serializer.fields['department'].read_only = True
return serializer
```
---
### 7.3 Дублирование логики синхронизации permissions
**Проблема**: `_ensure_department_permissions()` вызывается и в ViewSet, и в `Department.ui_context()`.
**Следствие**: При каждом GET запросе проверяется/создаётся справочник прав.
**Рекомендация**:
- Перенести синхронизацию в data migration или management команду
- Вызывать только при изменении `DeptPerm.CHOICES`
---
### 7.4 Нет версионирования API
**Проблема**: Backward-compat поля (`permissions`, `permissions_verbose`) добавлены без версионирования.
**Риск**: Накопление legacy полей в будущем.
**Рекомендация**: При следующем breaking change перейти на v2 API с чистой схемой.
---
## 8. Примеры использования
### 8.1 Создание роли с правами (по кодам)
```bash
POST /api/v1/department-roles/
Authorization: Bearer <token>
Content-Type: application/json
{
"department": 5,
"name": "Senior Developer",
"scoped_permission_codes": [
"manage_department",
"assign_department_role",
"view_request"
]
}
```
### 8.2 Обновление прав существующей роли
```bash
POST /api/v1/department-roles/12/set_perms/
Authorization: Bearer <token>
Content-Type: application/json
{
"permission_codes": [
"manage_department",
"change_department_head",
"manage_department_events"
]
}
```
### 8.3 Получение ролей отдела
```bash
GET /api/v1/department-roles/?department=5&ordering=name
Authorization: Bearer <token>
```
---
## 9. Итоговая схема взаимодействия
```
┌─────────────────────────────────────────────────────────┐
│ Frontend Request │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ DepartmentRoleViewSet.get_permissions() │
│ - list/retrieve: IsAuthenticated() │
│ - write ops: AssignRolePerm(AdminOrDeptAllowed) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ AdminOrDeptAllowed.has_permission() │
│ 1. Check: user.is_staff/is_superuser → Allow │
│ 2. Extract dept_id from request.data or obj │
│ 3. Check: user == dept.head → Allow │
│ 4. Check: has_dept_perm(user, dept_id, "assign_...") │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ has_dept_perm(user, dept_id, code) │
│ Query: EmployeeDepartment.filter( │
│ employee=user, department=dept_id, is_active=True, │
│ role__scoped_permissions__code=code │
│ ) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ DepartmentRoleSerializer │
│ - Serialize role with scoped_permissions (M2M) │
│ - Add backward-compat fields (permissions, verbose) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Response to Frontend │
└─────────────────────────────────────────────────────────┘
```
---
**Следующий отчет**: [03_DEPARTMENT_ROLES_SERIALIZERS.md](./03_DEPARTMENT_ROLES_SERIALIZERS.md) — Сериализаторы и валидация

View File

@ -0,0 +1,710 @@
# Отчет: Сериализаторы системы ролей отделов
**Дата**: 30.12.2025
**Анализ**: DepartmentRoleSerializer, SetMemberRoleInput — валидация, backward compatibility
---
## 1. DepartmentRoleSerializer — Основной сериализатор
### Расположение
`backend/api/v1/employees/serializers.py` (строки 589-675)
### Назначение
Сериализация/десериализация объектов `DepartmentRole` с поддержкой:
- Записи прав по ID или кодам
- Backward compatibility для старого API
- Валидации уникальности и принадлежности прав
---
## 2. Структура полей
### 2.1 Основные поля
```python
class DepartmentRoleSerializer(serializers.ModelSerializer):
scoped_permissions = serializers.PrimaryKeyRelatedField(
queryset=DepartmentPermission.objects.all(),
many=True,
required=False
)
scoped_permission_codes = serializers.ListField(
child=serializers.CharField(),
required=False,
write_only=True
)
# Backward-compat (read-only)
permissions = serializers.SerializerMethodField(read_only=True)
permissions_verbose = serializers.SerializerMethodField(read_only=True)
class Meta:
model = DepartmentRole
fields = (
"id",
"department",
"name",
# новые write-поля
"scoped_permissions",
"scoped_permission_codes",
# совместимость (read-only зеркала)
"permissions",
"permissions_verbose",
)
read_only_fields = ("id",)
```
### 2.2 Матрица полей
| Поле | Тип | Read/Write | Required | Назначение |
|------|-----|------------|----------|------------|
| `id` | Integer | Read-only | — | PK роли |
| `department` | Integer (FK) | Read/Write | Yes (create) | ID отдела |
| `name` | String | Read/Write | Yes | Название роли |
| `scoped_permissions` | List[Integer] | Read/Write | No | ID прав (новая схема) |
| `scoped_permission_codes` | List[String] | Write-only | No | Коды прав (альтернатива) |
| `permissions` | List[Integer] | Read-only | — | Legacy: ID прав |
| `permissions_verbose` | List[Object] | Read-only | — | Legacy: полная инфо прав |
---
## 3. Логика создания (CREATE)
### 3.1 Метод create()
```python
def create(self, validated_data):
# Вытаскиваем write-only/внешние поля до super().create()
codes = validated_data.pop("scoped_permission_codes", None)
perms = validated_data.pop("scoped_permissions", None)
role = super().create(validated_data)
if perms is not None:
role.scoped_permissions.set(perms)
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
if qs.count() != len(set(codes)):
raise serializers.ValidationError(
{"scoped_permission_codes": "Некоторые коды не существуют"}
)
role.scoped_permissions.set(list(qs))
return role
```
### 3.2 Порядок операций
```
1. validated_data = {department, name, scoped_permissions, scoped_permission_codes}
↓
2. Извлечь codes и perms из validated_data (pop)
↓
3. super().create(validated_data) # создать role без M2M
↓
4. Если есть perms → role.scoped_permissions.set(perms)
↓
5. Если есть codes → валидация → role.scoped_permissions.set(...)
↓
6. Return role
```
### 3.3 Приоритет
**Если указаны оба поля:**
```python
if perms is not None:
role.scoped_permissions.set(perms) # Применяется первым
if codes is not None:
# ... валидация ...
role.scoped_permissions.set(list(qs)) # Перезаписывает предыдущее!
```
**Вывод**: `scoped_permission_codes` имеет **больший приоритет** и перезапишет `scoped_permissions`.
---
## 4. Логика обновления (UPDATE)
### 4.1 Метод update()
```python
def update(self, instance, validated_data):
# Аналогично: вынимаем до super().update()
codes = validated_data.pop("scoped_permission_codes", None)
perms = validated_data.pop("scoped_permissions", None)
role = super().update(instance, validated_data)
if perms is not None:
role.scoped_permissions.set(perms)
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
if qs.count() != len(set(codes)):
raise serializers.ValidationError(
{"scoped_permission_codes": "Некоторые коды не существуют"}
)
role.scoped_permissions.set(list(qs))
return role
```
### 4.2 Особенности PATCH
**При PATCH запросе без указания прав:**
```json
PATCH /api/v1/department-roles/12/
{
"name": "New Name"
}
```
- `perms` и `codes` будут `None`
- Права **не изменятся** (не будут очищены)
- Обновится только `name`
**При явном указании пустого массива:**
```json
{
"scoped_permissions": []
}
```
- Все права будут **удалены**: `role.scoped_permissions.set([])`
---
## 5. Backward Compatibility
### 5.1 Поле permissions (read-only)
```python
def get_permissions(self, obj: DepartmentRole):
return list(obj.scoped_permissions.values_list("id", flat=True))
```
**Назначение**: Старый API возвращал список ID прав. Теперь это зеркало `scoped_permissions`.
**Пример ответа**:
```json
{
"id": 12,
"scoped_permissions": [1, 3, 5],
"permissions": [1, 3, 5] // дубликат для совместимости
}
```
### 5.2 Поле permissions_verbose (read-only)
```python
def get_permissions_verbose(self, obj: DepartmentRole):
return [
{"id": p.id, "code": p.code, "name": p.name}
for p in obj.scoped_permissions.order_by("code").all()
]
```
**Назначение**: Полная информация о правах с сортировкой по `code`.
**Пример ответа**:
```json
{
"permissions_verbose": [
{"id": 3, "code": "assign_department_role", "name": "Назначать роли участникам"},
{"id": 1, "code": "manage_department", "name": "Управлять отделом"}
]
}
```
**Сортировка**: По коду прав (алфавитный порядок).
---
## 6. Валидация
### 6.1 Валидация кодов прав
```python
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
if qs.count() != len(set(codes)):
raise serializers.ValidationError(
{"scoped_permission_codes": "Некоторые коды не существуют"}
)
```
**Проверки**:
1. `set(codes)` — устраняет дубликаты в запросе
2. `qs.count()` — количество найденных в БД
3. Если не совпадают → какие-то коды не существуют
**Пример ошибки**:
```json
{
"scoped_permission_codes": ["Некоторые коды не существуют"]
}
```
### 6.2 Валидация уникальности (department, name)
**Уровень проверки**: Constraint БД `unique_together = [('department', 'name')]`
**Ошибка при дубликате**:
```python
# Django автоматически поднимает IntegrityError
# DRF оборачивает в ValidationError
```
**Пример ответа**:
```json
{
"non_field_errors": ["The fields department, name must make a unique set."]
}
```
### 6.3 Валидация ID прав (scoped_permissions)
```python
scoped_permissions = serializers.PrimaryKeyRelatedField(
queryset=DepartmentPermission.objects.all(),
many=True,
required=False
)
```
**Автоматическая проверка**:
- Все указанные ID должны существовать в `DepartmentPermission`
- При несуществующем ID → `ValidationError`
---
## 7. SetMemberRoleInput — Назначение роли сотруднику
### Расположение
`backend/api/v1/employees/serializers.py` (строки 712-734)
### Назначение
Валидация payload для `Department.set_member_role()` endpoint.
### Структура
```python
class SetMemberRoleInput(serializers.Serializer):
# Поддерживаем алиасы: employee / role
employee_id = serializers.IntegerField(required=False)
role_id = serializers.IntegerField(allow_null=True, required=False)
employee = serializers.IntegerField(required=False)
role = serializers.IntegerField(allow_null=True, required=False)
is_active = serializers.BooleanField(required=False) # новый флаг
def validate(self, attrs):
# Нормализация алиасов
if "employee" in attrs and "employee_id" not in attrs:
attrs["employee_id"] = attrs.pop("employee")
if "role" in attrs and "role_id" not in attrs:
attrs["role_id"] = attrs.pop("role")
# Проверка обязательного employee_id
if "employee_id" not in attrs:
raise serializers.ValidationError(
{"employee_id": "This field is required."}
)
return attrs
```
### Поддерживаемые форматы
**Вариант 1: Явные суффиксы**
```json
{
"employee_id": 42,
"role_id": 12
}
```
**Вариант 2: Короткие имена**
```json
{
"employee": 42,
"role": 12
}
```
**Вариант 3: Снятие роли**
```json
{
"employee_id": 42,
"role_id": null // или просто не указывать поле
}
```
**Вариант 4: С флагом активности (новое)**
```json
{
"employee_id": 42,
"role_id": 12,
"is_active": false // деактивировать членство
}
```
### Особенности валидации
**employee_id обязателен**:
```python
if "employee_id" not in attrs:
raise serializers.ValidationError(...)
```
**role_id опционален**:
```python
role_id = serializers.IntegerField(allow_null=True, required=False)
```
- Если `null` или отсутствует → роль снимается
- Если указан → назначается
**is_active опционален**:
- Новое поле (недавно добавлено)
- Позволяет изменять активность членства при назначении роли
---
## 8. Проблемные места
### 8.1 Двойная установка прав при наличии обоих полей
**Проблема**:
```python
if perms is not None:
role.scoped_permissions.set(perms) # Шаг 1
if codes is not None:
# валидация
role.scoped_permissions.set(list(qs)) # Шаг 2: перезаписывает!
```
**Последствие**: Если клиент отправит оба поля, `scoped_permissions` будет проигнорирован.
**Решение**: Добавить валидацию:
```python
def validate(self, attrs):
if 'scoped_permissions' in attrs and 'scoped_permission_codes' in attrs:
raise serializers.ValidationError(
"Cannot specify both scoped_permissions and scoped_permission_codes"
)
return attrs
```
---
### 8.2 Нет валидации принадлежности роли к отделу
**Проблема**: На уровне сериализатора не проверяется, что `role.department_id == validated_data['department']`.
**Где защита**: Только в ViewSet и на уровне БД constraint.
**Риск**: Если сериализатор используется вне ViewSet, можно создать несогласованные данные.
**Решение**: Добавить кастомную валидацию:
```python
def validate(self, attrs):
role_id = attrs.get('role_id')
dept_id = attrs.get('department')
if role_id and dept_id:
role = DepartmentRole.objects.filter(id=role_id).first()
if role and role.department_id != dept_id:
raise serializers.ValidationError({
'role_id': 'Role must belong to the specified department'
})
return attrs
```
---
### 8.3 Дублирование логики валидации кодов
**Проблема**: Одинаковая логика в `create()` и `update()`:
```python
# В обоих методах:
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
if qs.count() != len(set(codes)):
raise serializers.ValidationError(...)
role.scoped_permissions.set(list(qs))
```
**Решение**: Вынести в приватный метод:
```python
def _apply_codes_if_present(self, instance: DepartmentRole, validated_data: dict):
codes = validated_data.pop("scoped_permission_codes", None)
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
if qs.count() != len(set(codes)):
raise serializers.ValidationError(
{"scoped_permission_codes": "Некоторые коды не существуют"}
)
instance.scoped_permissions.set(list(qs))
```
**Текущий код**: Этот метод уже существует (`_apply_codes_if_present`), но **не используется** в `create()`/`update()`! Дублирование осталось.
---
### 8.4 Backward-compat поля увеличивают размер ответа
**Проблема**:
```json
{
"scoped_permissions": [1, 3, 5],
"permissions": [1, 3, 5], // дубликат
"permissions_verbose": [
{"id": 1, "code": "manage_department", "name": "..."},
{"id": 3, "code": "assign_department_role", "name": "..."},
{"id": 5, "code": "view_request", "name": "..."}
]
}
```
**Последствие**: При списке из 50 ролей размер ответа удваивается из-за дублирования.
**Решение**:
- Использовать query param для включения legacy полей: `?include_legacy=true`
- Или перейти на v2 API без backward-compat
---
## 9. Использование в коде
### 9.1 В DepartmentRoleViewSet
```python
class DepartmentRoleViewSet(viewsets.ModelViewSet):
serializer_class = DepartmentRoleSerializer
# Автоматически используется в:
# - list() → many=True
# - retrieve() → один объект
# - create() → validation + создание
# - update()/partial_update() → validation + обновление
```
### 9.2 В DepartmentViewSet.ui_context()
```python
roles_qs = DepartmentRole.objects.filter(department_id=dept.id)
roles_data = DepartmentRoleSerializer(roles_qs, many=True).data
```
**Цель**: Получить все роли отдела для отображения в UI.
---
## 10. Сравнение с другими сериализаторами
### 10.1 GroupSerializer (Django Groups)
```python
class GroupSerializer(serializers.ModelSerializer):
permissions = serializers.PrimaryKeyRelatedField(
queryset=Permission.objects.all(), many=True, required=False
)
permissions_verbose = serializers.SerializerMethodField(read_only=True)
```
**Отличия**:
- Нет поддержки кодов (только ID)
- Нет backward-compat (новое поле)
- Работает с глобальными Django permissions
**Сходства**:
- M2M к permissions
- Verbose поле для полной информации
---
## 11. Примеры запросов/ответов
### 11.1 CREATE с кодами прав
**Request**:
```http
POST /api/v1/department-roles/
Content-Type: application/json
{
"department": 5,
"name": "Tech Lead",
"scoped_permission_codes": [
"manage_department",
"assign_department_role",
"manage_department_events"
]
}
```
**Response** (201):
```json
{
"id": 15,
"department": 5,
"name": "Tech Lead",
"scoped_permissions": [1, 3, 4],
"permissions": [1, 3, 4],
"permissions_verbose": [
{"id": 3, "code": "assign_department_role", "name": "Назначать роли участникам"},
{"id": 1, "code": "manage_department", "name": "Управлять отделом"},
{"id": 4, "code": "manage_department_events", "name": "Управлять календарём отдела"}
]
}
```
### 11.2 UPDATE только названия
**Request**:
```http
PATCH /api/v1/department-roles/15/
Content-Type: application/json
{
"name": "Senior Tech Lead"
}
```
**Response** (200):
```json
{
"id": 15,
"department": 5,
"name": "Senior Tech Lead",
"scoped_permissions": [1, 3, 4], // не изменились
...
}
```
### 11.3 Ошибка валидации кодов
**Request**:
```http
POST /api/v1/department-roles/
Content-Type: application/json
{
"department": 5,
"name": "Invalid Role",
"scoped_permission_codes": ["manage_department", "nonexistent_code"]
}
```
**Response** (400):
```json
{
"scoped_permission_codes": ["Некоторые коды не существуют"]
}
```
---
## 12. Рекомендации по улучшению
### 12.1 Использовать метод _apply_codes_if_present
```python
def create(self, validated_data):
perms = validated_data.pop("scoped_permissions", None)
role = super().create(validated_data)
if perms is not None:
role.scoped_permissions.set(perms)
self._apply_codes_if_present(role, validated_data) # ← использовать
return role
```
### 12.2 Добавить mutual exclusion валидацию
```python
def validate(self, attrs):
if 'scoped_permissions' in attrs and 'scoped_permission_codes' in attrs:
raise serializers.ValidationError(
"Specify either scoped_permissions or scoped_permission_codes, not both"
)
return attrs
```
### 12.3 Сделать department read-only при update
```python
class Meta:
model = DepartmentRole
fields = (...)
extra_kwargs = {
'department': {'required': True} # обязательно при create
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance is not None: # update
self.fields['department'].read_only = True
```
### 12.4 Оптимизировать backward-compat
```python
def __init__(self, *args, **kwargs):
include_legacy = kwargs.pop('include_legacy', False)
super().__init__(*args, **kwargs)
if not include_legacy:
self.fields.pop('permissions', None)
self.fields.pop('permissions_verbose', None)
```
**Использование**:
```python
serializer = DepartmentRoleSerializer(role, include_legacy=True)
```
---
## 13. Итоговая схема потока данных
```
┌────────────────────────────────────────┐
│ Client Request Body │
│ {department, name, │
│ scoped_permission_codes: [...]} │
└────────────────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ DepartmentRoleSerializer.to_internal │
│ - Validate fields │
│ - Convert codes to internal format │
└────────────────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ create() / update() │
│ 1. Pop codes and perms from data │
│ 2. super().create/update (basic) │
│ 3. Set M2M: scoped_permissions │
│ 4. Validate codes → query DB │
│ 5. Set M2M with codes if present │
└────────────────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ DepartmentRoleSerializer.to_represent │
│ - Add scoped_permissions │
│ - Add permissions (backward-compat) │
│ - Add permissions_verbose │
└────────────────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ Response with all fields │
└────────────────────────────────────────┘
```
---
**Следующий отчет**: [04_DEPARTMENT_ROLES_PERMISSIONS.md](./04_DEPARTMENT_ROLES_PERMISSIONS.md) — Система проверки прав

View File

@ -0,0 +1,763 @@
# Отчет: Система проверки прав в ролях отделов
**Дата**: 30.12.2025
**Анализ**: AdminOrDeptAllowed, has_dept_perm, user_has_dept_perm — архитектура permission system
---
## 1. Обзор архитектуры прав
### Уровни проверки прав
```
┌──────────────────────────────────────────────────────┐
│ Level 1: Django Model Permissions │
│ - Глобальные права на модель Department │
│ - Проверка: user.has_perm('employees.manage_...') │
└──────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ Level 2: Staff/Superuser Override │
│ - user.is_staff or user.is_superuser → ALL ACCESS │
└──────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ Level 3: Department Head Rights │
│ - dept.head == user → ALL DEPARTMENT RIGHTS │
└──────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ Level 4: Scoped Role Permissions │
│ - EmployeeDepartment.role.scoped_permissions │
│ - Права действуют ТОЛЬКО в конкретном отделе │
└──────────────────────────────────────────────────────┘
```
---
## 2. Функции проверки прав
### 2.1 user_is_staffish() — Проверка admin-доступа
**Расположение**: `backend/api/v1/permissions.py` (строки 26-28)
```python
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
```
**Назначение**: Быстрая проверка админского доступа.
**Использование**:
- В условиях before expensive queries
- Для ранних return в permission classes
---
### 2.2 user_is_dept_head() — Проверка руководителя
**Расположение**: `backend/api/v1/permissions.py` (строки 21-24)
```python
def user_is_dept_head(user, dept: Department) -> bool:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
```
**Назначение**: Проверить, является ли пользователь руководителем отдела.
**Особенности**:
- Принимает объект `Department` (не ID)
- Использует `getattr()` для безопасного доступа к `user.id`
**Использование**:
```python
if user_is_dept_head(request.user, department):
# Руководитель имеет полный доступ
return True
```
---
### 2.3 user_has_dept_perm() — Проверка скоуп-права
**Расположение**: `backend/api/v1/permissions.py` (строки 32-74)
```python
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code,
).exists()
```
### Логика проверки (порядок)
```
1. Аутентификация ──► Нет ──► False
│
Да
↓
2. Staff/Superuser ──► Да ──► True
│
Нет
↓
3. Валидация dept ──► Ошибка ──► ValueError
│
OK
↓
4. Руководитель отдела ──► Да ──► True
│
Нет
↓
5. Query EmployeeDepartment:
- employee_id = user.id
- department_id = dept.id
- is_active = True
- role__scoped_permissions__code = perm_code
│
↓
Exists? ──► Да ──► True
│
Нет
↓
False
```
### Особенности
**1. Принимает объект Department**:
```python
# ✅ Правильно:
user_has_dept_perm(user, department_obj, "manage_department")
# ❌ Неправильно:
user_has_dept_perm(user, 5, "manage_department") # ValueError
```
**2. Использует M2M join**:
```sql
SELECT 1 FROM employees_employeedepartment
JOIN employees_departmentrole_scoped_permissions
ON employees_departmentrole_scoped_permissions.departmentrole_id = employees_employeedepartment.role_id
JOIN employees_departmentpermission
ON employees_departmentpermission.id = employees_departmentrole_scoped_permissions.departmentpermission_id
WHERE employee_id = %s
AND department_id = %s
AND is_active = TRUE
AND code = %s
LIMIT 1
```
**3. Проверяет `is_active`**:
- Деактивированные членства игнорируются
- Даже если роль есть, но `is_active=False` → доступ запрещён
---
### 2.4 has_dept_perm() — Проверка по ID отдела
**Расположение**: `backend/api/v1/permissions.py` (строки 76-106)
```python
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True
3) пользователь состоит в отделе и его роль содержит `code` → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
```
### Отличия от user_has_dept_perm
| Аспект | user_has_dept_perm | has_dept_perm |
|--------|-------------------|---------------|
| Параметр отдела | `dept: Department` (объект) | `department_id: int` (ID) |
| Query руководителя | `dept.head_id == user.id` (в памяти) | `Department.objects.filter(...)` (DB) |
| Query прав | 1 запрос (M2M join) | 2 запроса (select_related + M2M) |
| Использование | Когда объект уже загружен | Когда есть только ID |
### Оптимизация
**has_dept_perm** использует:
```python
link = EmployeeDepartment.objects.select_related("role").filter(...).first()
```
**Цель**: Загрузить роль заранее, чтобы следующий запрос использовал кеш:
```python
return link.role.scoped_permissions.filter(code=code).exists()
```
**Результат**: 2 query вместо 3.
---
## 3. AdminOrDeptAllowed — Базовый permission class
### Расположение
`backend/api/v1/permissions.py` (строки 108-310)
### Назначение
Комбинированный permission class для DRF ViewSets:
- Админы имеют полный доступ
- Остальные проверяются по скоуп-правам в конкретном отделе
---
### 3.1 Структура класса
```python
class AdminOrDeptAllowed(BasePermission):
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
```
### Атрибуты класса
| Атрибут | Тип | По умолчанию | Назначение |
|---------|-----|--------------|------------|
| `required_code` | str \| None | None | Единый код права для всех действий |
| `required_code_map` | dict[str, str] | {} | Коды прав по имени action |
| `allow_safe_without_code` | bool | True | Разрешать ли GET без кода |
---
### 3.2 Методы
#### get_required_code()
```python
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
```
**Приоритет**:
1. `required_code_map[view.action]`
2. `required_code`
3. `None` (если оба пусты)
**Примеры**:
```python
class MyPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE # Для всех действий
class MyPerm2(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE,
"update": DeptPerm.MANAGE,
"destroy": DeptPerm.CHANGE_HEAD, # Другое право для удаления
}
```
---
#### _extract_dept_id_from_request()
```python
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
```
**Порядок приоритета**:
1. **URL kwargs** (`/departments/{id}/...` → `department_pk`)
2. **Request body** (`POST {"department": 5}`)
3. **Query params** (`?department=5`)
**Примеры**:
```python
# Вариант 1: Nested route
POST /api/v1/departments/5/roles/
→ view.kwargs = {"department_pk": 5}
# Вариант 2: Body
POST /api/v1/department-roles/
{"department": 5, "name": "Manager"}
→ request.data = {"department": 5}
# Вариант 3: Query
GET /api/v1/department-roles/?department=5
→ request.query_params = {"department": "5"}
```
---
#### _extract_dept_id_from_obj()
```python
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
1) obj.id (если obj сам Department)
2) obj.department_id / obj.dept_id
3) obj.department.id
"""
# 1) сам объект — Department
if isinstance(obj, Department):
return getattr(obj, "id", None)
# 2) прямые FK
for attr in ("department_id", "dept_id"):
dept_id = getattr(obj, attr, None)
if dept_id is not None:
return dept_id
# 3) related Department
dept = getattr(obj, "department", None)
if dept:
return getattr(dept, "id", None)
return None
```
**Примеры**:
```python
# 1. Объект Department
dept = Department.objects.get(id=5)
→ dept_id = 5
# 2. DepartmentRole
role = DepartmentRole.objects.get(id=12)
→ role.department_id = 5
# 3. Объект с related
class MyModel:
department = ForeignKey(Department)
obj = MyModel.objects.select_related('department').get(...)
→ obj.department.id = 5
```
---
#### has_permission() — Request-level check
```python
def has_permission(self, request, view):
"""Проверка на уровне запроса (до загрузки объекта).
Используется для:
- list/create (нет конкретного объекта)
- Определения dept_id из request
"""
user = request.user
# 1. Админы всегда проходят
if user_is_staffish(user):
return True
# 2. Получить требуемый код
code = self.get_required_code(request, view)
# 3. Если код не требуется
if not code:
# SAFE методы разрешены (GET/HEAD/OPTIONS)
if request.method in SAFE_METHODS and self.allow_safe_without_code:
return True
return False # Небезопасные без кода → запрет
# 4. Извлечь dept_id
dept_id = self._extract_dept_id_from_request(request, view)
if not dept_id:
return False # Не можем определить отдел → запрет
# 5. Проверить право в отделе
return has_dept_perm(user, dept_id, code)
```
**Логика потока**:
```
Request → has_permission()
↓
1. Staff? → Yes → Allow
↓ No
2. Get code (action-specific or default)
↓
3. Code is None?
↓ Yes
SAFE method & allow_safe_without_code? → Allow
↓ No → Deny
4. Extract dept_id from request
↓
5. has_dept_perm(user, dept_id, code) → Allow/Deny
```
---
#### has_object_permission() — Object-level check
```python
def has_object_permission(self, request, view, obj):
"""Проверка на уровне объекта (после загрузки).
Используется для:
- retrieve/update/destroy (есть конкретный объект)
- Определения dept_id из самого объекта
"""
user = request.user
# 1. Админы всегда проходят
if user_is_staffish(user):
return True
# 2. Получить код
code = self.get_required_code(request, view)
# 3. Если код не требуется
if not code:
if request.method in SAFE_METHODS and self.allow_safe_without_code:
return True
return False
# 4. Извлечь dept_id из объекта
dept_id = self._extract_dept_id_from_obj(obj)
if not dept_id:
return False
# 5. Проверить право
return has_dept_perm(request.user, dept_id, code)
```
**Отличие от has_permission**:
- Извлечение `dept_id` из **obj**, а не из request
- Используется после `get_object()` в ViewSet
---
## 4. Наследники AdminOrDeptAllowed
### 4.1 В DepartmentViewSet
```python
class ManagePerm(AdminOrDeptAllowed):
"""Право на управление отделом (add/remove members)."""
required_code = DeptPerm.MANAGE
class ChangeHeadPerm(AdminOrDeptAllowed):
"""Право на назначение руководителя."""
required_code = DeptPerm.CHANGE_HEAD
class AssignRolePerm(AdminOrDeptAllowed):
"""Право на назначение ролей участникам отдела."""
required_code = DeptPerm.ASSIGN_ROLE
```
**Использование**:
```python
def get_permissions(self):
if self.action == 'set_head':
return [self.ChangeHeadPerm()]
if self.action in {'add_member', 'remove_member'}:
return [self.ManagePerm()]
if self.action == 'set_member_role':
return [self.AssignRolePerm()]
...
```
---
### 4.2 В DepartmentRoleViewSet
```python
class AssignRolePerm(AdminOrDeptAllowed):
required_code = DeptPerm.ASSIGN_ROLE
def get_permissions(self):
if self.action in {"create", "update", "partial_update", "destroy", "set_perms"}:
return [self.AssignRolePerm()]
return [IsAuthenticated()]
```
**Логика**:
- Создание/изменение/удаление ролей требует `assign_department_role`
- Чтение ролей доступно всем аутентифицированным
---
### 4.3 В Calendar ViewSet
```python
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
```
**Использование**: Управление календарём событий отдела.
---
## 5. Проблемные места
### 5.1 Дублирование логики в двух функциях
**Проблема**: `user_has_dept_perm` и `has_dept_perm` делают почти одно и то же, но по-разному.
**Следствие**:
- Две кодовые ветки для поддержки
- Разная производительность (1 vs 2 запроса)
- Риск рассинхронизации логики
**Решение**: Унифицировать в одну функцию:
```python
def has_dept_perm(user, dept: Department | int, code: str) -> bool:
if isinstance(dept, int):
dept_id = dept
# Query Department if needed
else:
dept_id = dept.id
# Unified logic
```
---
### 5.2 Нет кеширования результатов проверки
**Проблема**: При множественных проверках в рамках одного request делаются повторные запросы.
**Пример**:
```python
# В одном request:
has_dept_perm(user, 5, "manage_department") # Query 1
has_dept_perm(user, 5, "assign_department_role") # Query 2
has_dept_perm(user, 5, "manage_department") # Query 3 (дубликат)
```
**Решение**: Использовать `@lru_cache` или request-level кеш:
```python
from functools import lru_cache
@lru_cache(maxsize=128)
def has_dept_perm(user_id: int, dept_id: int, code: str) -> bool:
# ... логика ...
```
---
### 5.3 Неочевидный приоритет в _extract_dept_id_from_request
**Проблема**: Порядок извлечения может привести к неожиданному поведению.
**Пример**:
```http
POST /api/v1/departments/5/roles/
{"department": 10} # Другой ID в body
→ Используется view.kwargs["department_pk"] = 5
→ Body игнорируется
```
**Решение**: Добавить валидацию конфликтов:
```python
kwargs_id = view.kwargs.get("department_pk")
body_id = request.data.get("department")
if kwargs_id and body_id and kwargs_id != body_id:
raise PermissionDenied("Conflicting department IDs in URL and body")
```
---
### 5.4 ValueError при невалидном dept в user_has_dept_perm
**Проблема**:
```python
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance...")
```
**Следствие**: В production ValueError не обрабатывается DRF, возвращается 500.
**Решение**: Использовать `PermissionDenied`:
```python
from rest_framework.exceptions import PermissionDenied
if not isinstance(dept, Department):
raise PermissionDenied("Invalid department object")
```
---
## 6. Примеры использования
### 6.1 Проверка прав в кастомном action
```python
@action(detail=True, methods=["post"])
def custom_action(self, request, pk=None):
dept = self.get_object()
# Проверить конкретное право
if not user_has_dept_perm(request.user, dept, DeptPerm.MANAGE):
return Response({"detail": "No permission"}, status=403)
# Бизнес-логика
...
```
### 6.2 Условная логика на основе прав
```python
def get_queryset(self):
qs = super().get_queryset()
user = self.request.user
if user.is_staff:
return qs # Все отделы
# Только отделы, где есть право MANAGE
dept_ids = EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code=DeptPerm.MANAGE
).values_list('department_id', flat=True)
return qs.filter(id__in=dept_ids)
```
### 6.3 Множественные права (OR логика)
```python
def has_any_dept_perm(user, dept_id: int, codes: list[str]) -> bool:
"""Проверить наличие хотя бы одного из прав."""
if user.is_staff or user.is_superuser:
return True
if Department.objects.filter(id=dept_id, head_id=user.id).exists():
return True
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept_id,
is_active=True,
role__scoped_permissions__code__in=codes
).exists()
```
---
## 7. Итоговая схема проверки прав
```
┌────────────────────────────────────────────────────┐
│ DRF Request │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ ViewSet.check_permissions() │
│ → AdminOrDeptAllowed.has_permission(request, view)│
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ 1. user_is_staffish(user)? │
│ Yes → Allow │
└────────────────────────────────────────────────────┘
↓ No
┌────────────────────────────────────────────────────┐
│ 2. get_required_code(request, view) │
│ → DeptPerm.ASSIGN_ROLE │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ 3. _extract_dept_id_from_request(request, view) │
│ → dept_id = 5 │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ 4. has_dept_perm(user, dept_id, code) │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ 5a. Department.filter(id=dept_id, head=user)? │
│ Yes → True │
└────────────────────────────────────────────────────┘
↓ No
┌────────────────────────────────────────────────────┐
│ 5b. EmployeeDepartment.filter( │
│ employee=user, department=dept_id, │
│ is_active=True, │
│ role__scoped_permissions__code=code │
│ ).exists()? │
│ Yes → True, No → False │
└────────────────────────────────────────────────────┘
```
---
**Следующий отчет**: [05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md](./05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md) — Константы и утилиты

View File

@ -0,0 +1,647 @@
# Отчет: Константы и утилиты системы ролей
**Дата**: 30.12.2025
**Анализ**: DeptPerm, _ensure_department_permissions, вспомогательные функции
---
## 1. DeptPerm — Справочник кодов прав
### Расположение
`backend/employees/constants.py` (строки 30-55)
### Структура
```python
class DeptPerm:
MANAGE = "manage_department"
CHANGE_HEAD = "change_department_head"
ASSIGN_ROLE = "assign_department_role"
MANAGE_CALENDAR = "manage_department_events"
CREATE_POST = "publish_department_post"
MANAGE_FEED = "manage_department_feed"
VIEW_REQUESTCOMMENT = "view_requestcomment"
ADD_REQUESTCOMMENT = "add_requestcomment"
VIEW_REQUEST = "view_request"
CAN_PROCESS_REQUESTS = "can_process_requests"
CHOICES = (
(MANAGE, "Управлять отделом"),
(CHANGE_HEAD, "Назначать руководителя"),
(ASSIGN_ROLE, "Назначать роли участникам"),
(MANAGE_CALENDAR, "Управлять календарём отдела"),
(CREATE_POST, "Публиковать новости на странице отдела"),
(MANAGE_FEED, "Редактировать публикации отдела"),
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
(VIEW_REQUEST, "Просмотр заявлений отдела"),
(CAN_PROCESS_REQUESTS, "Рассмотрение заявлений отдела"),
)
```
### Назначение
Единая точка истины для всех кодов прав отделов. Используется:
- В `AdminOrDeptAllowed` классах для указания `required_code`
- В `_ensure_department_permissions()` для синхронизации с БД
- В проверках прав `has_dept_perm(user, dept_id, DeptPerm.MANAGE)`
---
## 2. Категории прав
### 2.1 Управление отделом
| Код | Константа | Описание | Использование |
|-----|-----------|----------|---------------|
| `manage_department` | `DeptPerm.MANAGE` | Добавление/удаление участников | `add_member`, `remove_member` |
| `change_department_head` | `DeptPerm.CHANGE_HEAD` | Назначение руководителя | `set_head` |
| `assign_department_role` | `DeptPerm.ASSIGN_ROLE` | Назначение ролей участникам | `set_member_role`, CRUD ролей |
### 2.2 Контент отдела
| Код | Константа | Описание | Использование |
|-----|-----------|----------|---------------|
| `manage_department_events` | `DeptPerm.MANAGE_CALENDAR` | Управление календарём | CalendarViewSet |
| `publish_department_post` | `DeptPerm.CREATE_POST` | Публикация новостей | FeedViewSet |
| `manage_department_feed` | `DeptPerm.MANAGE_FEED` | Редактирование публикаций | FeedViewSet |
### 2.3 Заявки отдела
| Код | Константа | Описание | Использование |
|-----|-----------|----------|---------------|
| `view_request` | `DeptPerm.VIEW_REQUEST` | Просмотр заявлений | RequestsViewSet |
| `view_requestcomment` | `DeptPerm.VIEW_REQUESTCOMMENT` | Просмотр комментариев | RequestsViewSet |
| `add_requestcomment` | `DeptPerm.ADD_REQUESTCOMMENT` | Добавление комментариев | RequestsViewSet |
| `can_process_requests` | `DeptPerm.CAN_PROCESS_REQUESTS` | Рассмотрение заявлений | RequestsViewSet |
---
## 3. _ensure_department_permissions() — Синхронизация БД
### Расположение
`backend/employees/utils.py` (строки 91-107)
### Назначение
Идемпотентная синхронизация справочника `DepartmentPermission` с `DeptPerm.CHOICES`.
### Код
```python
def _ensure_department_permissions() -> list[dict]:
"""
Гарантирует наличие записей DepartmentPermission на основе DeptPerm.CHOICES.
Возвращает список словарей {id, code, name} в порядке CHOICES.
"""
items: list[dict] = []
# пробежимся по CHOICES, создадим/обновим имя, соберём выдачу
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": label}
)
# если имя в БД отстаёт от CHOICES — мягко синхронизируем
if obj.name != label:
obj.name = label
obj.save(update_fields=["name"])
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
return items
```
### Логика работы
```
Для каждого (code, label) в DeptPerm.CHOICES:
↓
1. get_or_create(code=code, defaults={"name": label})
↓
2. Если obj.name != label (имя изменилось в коде):
↓
obj.name = label
obj.save()
↓
3. Добавить {id, code, name} в результат
↓
Вернуть list[dict]
```
### Особенности
**Идемпотентность**:
- Можно вызывать многократно без побочных эффектов
- При повторном вызове не создаёт дубликаты
**Автоматическое обновление имён**:
```python
# Было в константах:
(MANAGE, "Управлять отделом")
# Изменили на:
(MANAGE, "Полное управление отделом")
# При следующем вызове _ensure_department_permissions():
obj.name = "Полное управление отделом" # Автообновление
```
**НЕ удаляет устаревшие**:
- Если код удалён из `DeptPerm.CHOICES`, запись в БД остаётся
- Ручная очистка через admin или migration
---
### Вызовы в коде
**1. DepartmentRoleViewSet.perm_choices()**:
```python
@action(detail=False, methods=["get"])
def perm_choices(self, request):
data = _ensure_department_permissions()
return Response({"count": len(data), "results": data}, status=200)
```
**2. Department.ui_context()**:
```python
perm_choices = _perm_choices_synced() # Вызывает _ensure_department_permissions
```
---
## 4. _perm_choices_synced() — Алиас для синхронизации
### Расположение
`backend/employees/utils.py` (строки 158-171)
### Код
```python
def _perm_choices_synced() -> list[dict]:
"""
Возвращает справочник прав для ролей отдела, синхронизируя записи с DeptPerm.CHOICES.
"""
items = []
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": label}
)
if obj.name != label:
obj.name = label
obj.save(update_fields=["name"])
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
return items
```
### Назначение
Дубликат `_ensure_department_permissions()` с другим именем. Используется в `Department.ui_context()`.
### Проблема
Две функции делают одно и то же → техдолг.
---
## 5. _head_choices_for_dept() — Кандидаты на руководителя
### Расположение
`backend/employees/utils.py` (строки 110-155)
### Назначение
Возвращает список сотрудников, которые могут быть назначены руководителями отдела.
### Код
```python
def _head_choices_for_dept(dept: Department, serializer) -> list[dict]:
"""
Вернёт список кандидатов для назначения руководителя отдела.
Формат элемента:
{
"id": int, # ID сотрудника
"name": str, # display_name из EmployeeBriefSerializer
"email": str # email сотрудника (может быть пустой строкой)
}
"""
choices, seen = [], set()
qs = (
EmployeeDepartment.objects.filter(department_id=dept.id)
.select_related("employee")
.order_by(
"employee__last_name",
"employee__first_name",
"employee__patronymic",
"employee_id",
)
)
for link in qs:
data = serializer(link.employee).data
if data["id"] not in seen:
choices.append(
{
"id": data["id"],
"name": data["display_name"],
"email": data.get("email", "") or "",
}
)
seen.add(data["id"])
# Если текущий руководитель не в списке членов → добавить в начало
if dept.head_id and dept.head_id not in seen:
head_data = serializer(dept.head).data
choices.insert(
0,
{
"id": head_data["id"],
"name": head_data["display_name"],
"email": head_data.get("email", "") or "",
},
)
return choices
```
### Логика
```
1. Получить всех членов отдела (EmployeeDepartment)
↓
2. Сортировка: last_name, first_name, patronymic, id
↓
3. Для каждого члена:
- Сериализовать employee
- Если id уже в seen → пропустить (дедупликация)
- Добавить {id, name, email} в choices
↓
4. Если текущий head не в choices:
- Сериализовать dept.head
- Вставить в начало списка
↓
5. Вернуть choices
```
### Особенности
**Дедупликация через seen**:
- Защита от дублей при некорректных данных
**Текущий руководитель всегда в списке**:
- Даже если не является членом отдела
- Вставляется в позицию 0 (первый элемент)
**Сортировка по ФИО**:
- Удобный порядок для UI dropdown
---
## 6. _build_links_for_dept() — Список участников отдела
### Расположение
`backend/employees/utils.py` (строки 174-203)
### Назначение
Возвращает полную информацию о членстве сотрудников в отделе (для UI).
### Код (начало)
```python
def _build_links_for_dept(dept: Department, serializer) -> list[dict]:
"""
Возвращает список линков отдела:
[{
"employee": <EmployeeBriefSerializer.data>,
"role": {"id","name"}|None,
"is_active": bool
}, ...]
"""
links: list[dict] = []
qs = (
EmployeeDepartment.objects.filter(department_id=dept.id)
.select_related("employee", "role")
.order_by(
"employee__last_name",
"employee__first_name",
"employee__patronymic",
"employee_id",
)
)
for link in qs:
emp_data = serializer(link.employee).data
role_data = None
if link.role:
role_data = {"id": link.role.id, "name": link.role.name}
links.append({
"employee": emp_data,
"role": role_data,
"is_active": link.is_active,
})
return links
```
### Формат ответа
```json
[
{
"employee": {
"id": 42,
"display_name": "Иванов Иван Иванович",
"email": "ivanov@example.com",
"position": "Разработчик"
},
"role": {
"id": 12,
"name": "Tech Lead"
},
"is_active": true
},
{
"employee": {
"id": 43,
"display_name": "Петров Пётр",
"email": "petrov@example.com"
},
"role": null,
"is_active": true
}
]
```
### Использование
**В Department.members()**:
```python
@action(detail=True, methods=["get"], url_path="members")
def members(self, request, pk=None):
dept = self.get_object()
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
return Response({"count": len(links), "results": links}, status=200)
```
**В Department.ui_context()**:
```python
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
payload = {
"dept": dept_data,
"roles": roles_data,
"links": links, # ← здесь
...
}
```
---
## 7. Проблемные места
### 7.1 Дублирование _ensure и _perm_choices_synced
**Проблема**: Две идентичные функции.
```python
# _ensure_department_permissions()
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(...)
# ... логика ...
# _perm_choices_synced()
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(...)
# ... та же логика ...
```
**Решение**: Удалить одну, использовать алиас:
```python
_perm_choices_synced = _ensure_department_permissions
```
---
### 7.2 N+1 запросов в _build_links_for_dept
**Проблема**: Для каждого link вызывается `serializer(link.employee).data`.
**Текущая защита**: `select_related("employee", "role")` — предзагрузка.
**Оптимизация**: Использовать `prefetch_related` для вложенных данных:
```python
qs = (
EmployeeDepartment.objects.filter(department_id=dept.id)
.select_related("employee", "role", "employee__position")
.prefetch_related("employee__skills")
)
```
---
### 7.3 Частые вызовы синхронизации
**Проблема**: `_ensure_department_permissions()` вызывается при каждом GET запросе к `/perm_choices/` и `/ui-context/`.
**Следствие**:
- Множественные `get_or_create` даже когда ничего не изменилось
- Нагрузка на БД
**Решение**: Кешировать результат:
```python
from django.core.cache import cache
def _ensure_department_permissions() -> list[dict]:
cache_key = "dept_permissions_v1"
cached = cache.get(cache_key)
if cached:
return cached
items = []
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(...)
# ...
items.append(...)
cache.set(cache_key, items, timeout=3600) # 1 час
return items
```
**Инвалидация кеша**: При изменении `DeptPerm.CHOICES` (редко).
---
### 7.4 Нет защиты от удалённых кодов
**Проблема**: Если код удалён из `DeptPerm.CHOICES`, запись в БД остаётся.
**Сценарий**:
```python
# Было:
SOME_PERM = "some_permission"
CHOICES = (..., (SOME_PERM, "Какое-то право"), ...)
# Стало (удалили):
CHOICES = (..., (OTHER_PERM, "Другое право"), ...)
```
**Следствие**:
- В БД остаётся `DepartmentPermission(code="some_permission")`
- Роли могут ссылаться на устаревшее право
- Проверки прав будут работать, но семантика нарушена
**Решение**: Data migration при удалении кодов:
```python
def remove_obsolete_permission(apps, schema_editor):
DepartmentPermission = apps.get_model('employees', 'DepartmentPermission')
DepartmentPermission.objects.filter(code='some_permission').delete()
```
---
### 7.5 Жёсткая зависимость от serializer в utils
**Проблема**: Функции принимают `serializer` как параметр:
```python
def _build_links_for_dept(dept: Department, serializer) -> list[dict]:
# ... использует serializer(link.employee).data
```
**Следствие**:
- Функция не может работать без сериализатора
- Сложно тестировать в изоляции
**Решение**: Передавать функцию преобразования или использовать словари:
```python
def _build_links_for_dept(dept: Department, transform_fn=None) -> list[dict]:
if transform_fn is None:
transform_fn = lambda emp: {"id": emp.id, "name": emp.get_full_name()}
# ...
```
---
## 8. Использование констант в коде
### 8.1 В Permission Classes
```python
class ManagePerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE # ← использование константы
class AssignRolePerm(AdminOrDeptAllowed):
required_code = DeptPerm.ASSIGN_ROLE
```
### 8.2 В проверках прав
```python
if has_dept_perm(request.user, dept.id, DeptPerm.MANAGE):
# Разрешено управление отделом
...
```
### 8.3 В тестах
```python
def test_manage_perm_required():
ensure_dept_perm(DeptPerm.MANAGE, "Управлять отделом")
role = make_role(dept, "Manager", [DeptPerm.MANAGE])
# ...
```
---
## 9. Рекомендации по улучшению
### 9.1 Унифицировать функции синхронизации
```python
# Оставить одну функцию
def ensure_department_permissions() -> list[dict]:
# ... логика ...
# Создать алиас для обратной совместимости
_ensure_department_permissions = ensure_department_permissions
_perm_choices_synced = ensure_department_permissions
```
### 9.2 Добавить management команду
```python
# employees/management/commands/sync_dept_permissions.py
class Command(BaseCommand):
def handle(self, *args, **options):
items = _ensure_department_permissions()
self.stdout.write(f"Synced {len(items)} permissions")
```
**Использование**:
```bash
python manage.py sync_dept_permissions
```
### 9.3 Логировать изменения
```python
def _ensure_department_permissions() -> list[dict]:
items = []
for code, label in DeptPerm.CHOICES:
obj, created = DepartmentPermission.objects.get_or_create(...)
if created:
logger.info(f"Created DepartmentPermission: {code}")
elif obj.name != label:
logger.info(f"Updated name: {code} '{obj.name}' → '{label}'")
obj.name = label
obj.save()
items.append(...)
return items
```
### 9.4 Типизация возвращаемых значений
```python
from typing import TypedDict
class PermChoiceDict(TypedDict):
id: int
code: str
name: str
def _ensure_department_permissions() -> list[PermChoiceDict]:
# ...
```
---
## 10. Итоговая схема взаимодействия
```
┌───────────────────────────────────┐
│ DeptPerm.CHOICES (constants.py) │
│ - Single source of truth │
└───────────────────────────────────┘
↓
┌───────────────────────────────────┐
│ _ensure_department_permissions() │
│ - Sync DB with constants │
│ - Create missing entries │
│ - Update names │
└───────────────────────────────────┘
↓
┌───────────────────────────────────┐
│ DepartmentPermission (DB table) │
│ - Persistent storage │
│ - Referenced by roles via M2M │
└───────────────────────────────────┘
↓
┌───────────────────────────────────┐
│ DepartmentRole.scoped_permissions │
│ - M2M to DepartmentPermission │
└───────────────────────────────────┘
↓
┌───────────────────────────────────┐
│ EmployeeDepartment.role │
│ - FK to DepartmentRole │
└───────────────────────────────────┘
↓
┌───────────────────────────────────┐
│ has_dept_perm() checks │
│ - Query: role__scoped_perm__code │
└───────────────────────────────────┘
```
---
**Следующий отчет**: [06_DEPARTMENT_ROLES_LDAP.md](./06_DEPARTMENT_ROLES_LDAP.md) — LDAP интеграция

View File

@ -0,0 +1,625 @@
# Отчет: LDAP интеграция системы ролей
**Дата**: 30.12.2025
**Анализ**: DirectoryService, синхронизация ролей с LDAP-группами, OU=Roles
---
## 1. Обзор LDAP-интеграции
### Архитектура LDAP для ролей
```
OU=Departments,DC=example,DC=com
│
├── OU=IT Department
│ ├── OU=Roles ← Контейнер ролей отдела
│ │ ├── CN=Tech Lead ← LDAP-группа роли
│ │ ├── CN=Senior Developer
│ │ └── CN=Junior Developer
│ ├── CN=IT Department (group) ← Общая группа отдела
│ ├── CN=User1 ← Сотрудники
│ └── CN=User2
│
└── OU=HR Department
├── OU=Roles
│ └── CN=HR Manager
└── ...
```
### Ключевые сущности
1. **OU=Departments**: Корневой контейнер для всех отделов
2. **OU=<Department>**: Organizational Unit отдела
3. **OU=Roles**: Подконтейнер внутри отдела для групп ролей
4. **CN=<Role>**: LDAP-группы, соответствующие ролям
---
## 2. DirectoryService — Главный сервис
### Расположение
`backend/employees/ldap/directory_service.py`
### Структура
```python
class DirectoryService:
def __init__(self):
from .services.department_service import DepartmentService
from .services.user_service import UserService
from .services.group_service import GroupService
self._user_service = UserService()
self._group_service = GroupService(directory_service=self)
self._department_service = DepartmentService(
group_service=self._group_service,
user_service=self._user_service
)
```
### Делегирование
`DirectoryService` делегирует работу специализированным сервисам:
- **UserService**: Управление пользователями
- **GroupService**: Управление группами
- **DepartmentService**: Управление отделами и ролями
- **PositionService**: Управление должностями
---
## 3. set_member_role() — Назначение роли
### 3.1 Публичный метод DirectoryService
**Расположение**: `backend/employees/ldap/directory_service.py` (строки 166-170)
```python
def set_member_role(
self, dept: Department, employee: Employee, role: Optional[DepartmentRole]
) -> None:
"""Меняет роль участника с синхронизацией LDAP-групп Roles."""
return self._department_service.set_member_role(dept, employee, role)
```
**Делегирование**: Передаёт вызов в `DepartmentService`.
---
### 3.2 Реализация в DepartmentService
**Расположение**: `backend/employees/ldap/services/department_service.py` (строки 531-577)
```python
def set_member_role(
self, dept: Department, employee: Employee, role
) -> None:
"""Меняет роль участника отдела с синхронизацией LDAP-групп Roles.
Args:
dept: Отдел.
employee: Сотрудник.
role: Новая роль или None.
Raises:
DirectoryDbError: Ошибка при обновлении БД.
DirectoryLdapError: Ошибка при синхронизации групп.
"""
# 1. Обновление БД
try:
with transaction.atomic():
link = EmployeeDepartment.objects.get(
employee_id=employee.id, department_id=dept.id
)
link.role = role
link.save(update_fields=["role"])
except EmployeeDepartment.DoesNotExist as e:
raise DirectoryDbError(
"Employee is not a member of this department"
) from e
except Exception as e:
raise DirectoryDbError(str(e)) from e
# 2. Синхронизация LDAP
try:
if role:
user_dn = self._user_service._get_employee_dn(employee)
dept_dn = self._get_department_dn(dept)
roles_base = f"OU=Roles,{dept_dn}"
with _ldap() as conn:
sync_user_groups_by_cns(
conn,
user_dn,
{role.name}, # Синхронизировать только с группой role.name
extra_bases=[roles_base],
do_write=True,
)
except DirectoryServiceError:
pass # Игнорируем ошибки сервиса
except Exception as e:
raise DirectoryLdapError(
f"LDAP role sync failed: {e}"
) from e
```
### Порядок операций
```
1. DB Transaction (atomic)
↓
EmployeeDepartment.objects.get(...)
↓
link.role = role
↓
link.save()
↓
Commit
2. LDAP Sync (best effort)
↓
Получить user_dn (DN сотрудника)
↓
Получить dept_dn (DN отдела)
↓
roles_base = "OU=Roles,<dept_dn>"
↓
sync_user_groups_by_cns(conn, user_dn, {role.name}, extra_bases=[roles_base])
↓
Если ошибка → логировать, но не падать
```
### Ключевая особенность: Best Effort LDAP
```python
try:
# ... LDAP синхронизация ...
except DirectoryServiceError:
pass # Игнорируем
except Exception as e:
raise DirectoryLdapError(...)
```
**Значение**:
- БД обновляется всегда (transaction atomic)
- LDAP — best effort (ошибки не прерывают операцию)
- При падении LDAP роль назначена в БД, но не синхронизирована
---
## 4. sync_user_groups_by_cns() — Синхронизация групп
### Назначение
Синхронизирует членство пользователя в LDAP-группах по списку CN (Common Names).
### Логика
```python
def sync_user_groups_by_cns(
conn: Connection,
user_dn: str,
target_cns: Set[str],
extra_bases: List[str],
do_write: bool
):
"""
Приводит членство пользователя в LDAP-группах к целевому набору CN.
Args:
conn: LDAP соединение
user_dn: DN пользователя
target_cns: Набор CN групп, в которых пользователь должен быть
extra_bases: Дополнительные DN для поиска групп (например, OU=Roles)
do_write: Выполнять ли реальные изменения
"""
```
### Операции
```
1. Найти все группы в extra_bases (OU=Roles,OU=Dept)
↓
2. Текущие группы пользователя: current_cns = {...}
↓
3. Вычислить разницу:
- to_add = target_cns - current_cns (нужно добавить)
- to_remove = current_cns - target_cns (нужно удалить)
↓
4. Если do_write:
- Для каждой группы в to_add:
conn.modify(group_dn, {'member': [(MODIFY_ADD, [user_dn])]})
- Для каждой группы в to_remove:
conn.modify(group_dn, {'member': [(MODIFY_DELETE, [user_dn])]})
```
### Пример
**До синхронизации**:
- Сотрудник в группах: `["Tech Lead", "Senior Developer"]`
**Назначена роль**: `role.name = "Junior Developer"`
**После синхронизации**:
- Удалён из: `["Tech Lead", "Senior Developer"]`
- Добавлен в: `["Junior Developer"]`
---
## 5. Интеграция с API
### 5.1 В Department.set_member_role()
**Расположение**: `backend/api/v1/employees/views.py` (строки 956-1030)
```python
@action(detail=True, methods=["post"])
@transaction.atomic
def set_member_role(self, request, pk=None):
dept = self.get_object()
# ... валидация ...
svc = DirectoryService() if _is_ldap_enabled() else None
if svc:
# Режим с LDAP: синхронизируем роли
try:
svc.set_member_role(dept, employee, role)
link = EmployeeDepartment.objects.get(...)
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
return Response({"detail": str(e)}, status=...)
else:
# Режим без LDAP: просто обновляем роль в линке
link = EmployeeDepartment.objects.get(...)
link.role = role
link.save(update_fields=["role"])
return Response({...}, status=200)
```
### Две ветки выполнения
**1. LDAP включён (`_is_ldap_enabled() = True`)**:
```
API call → DirectoryService.set_member_role()
↓
1. DB: EmployeeDepartment.role = new_role
2. LDAP: sync groups in OU=Roles
↓
Response
```
**2. LDAP выключен**:
```
API call → Прямое обновление БД
↓
EmployeeDepartment.role = new_role
↓
Response
```
---
### 5.2 Проверка _is_ldap_enabled()
**Расположение**: Предположительно в `employees/ldap/__init__.py` или `settings.py`
```python
def _is_ldap_enabled() -> bool:
return getattr(settings, 'LDAP_ENABLED', False) and \
getattr(settings, 'LDAP_SERVER_URI', None) is not None
```
**Конфигурация** (`settings.py`):
```python
LDAP_ENABLED = True
LDAP_SERVER_URI = 'ldap://dc.example.com'
LDAP_BIND_DN = 'CN=Admin,DC=example,DC=com'
LDAP_BIND_PASSWORD = '...'
LDAP_DEPARTMENTS_BASE = 'OU=Departments,DC=example,DC=com'
```
---
## 6. Хранение DN в моделях
### 6.1 DepartmentRole.ldap_group_dn
```python
class DepartmentRole(models.Model):
# ...
ldap_group_dn = models.CharField(max_length=500, blank=True, default='')
```
**Назначение**: Хранит DN LDAP-группы роли.
**Пример значения**:
```
CN=Tech Lead,OU=Roles,OU=IT Department,OU=Departments,DC=example,DC=com
```
**Использование**:
- Прямой доступ к группе роли в LDAP
- Быстрый поиск без построения DN
---
### 6.2 LdapSyncState
```python
class LdapSyncState(models.Model):
model = models.CharField(max_length=50, db_index=True) # 'employee', 'department', ...
object_pk = models.CharField(max_length=50, db_index=True)
ldap_dn = models.CharField(max_length=500, blank=True, default='')
last_sync = models.DateTimeField(auto_now=True)
sync_status = models.CharField(max_length=20, default='synced')
```
**Назначение**: Mapping между объектами Django и LDAP DN.
**Примеры**:
```python
LdapSyncState(model='employee', object_pk='42',
ldap_dn='CN=Ivanov,OU=IT,OU=Departments,...')
LdapSyncState(model='department', object_pk='5',
ldap_dn='OU=IT Department,OU=Departments,...')
```
**Использование**:
- `_get_employee_dn()` → запрос к LdapSyncState
- `_get_department_dn()` → запрос к LdapSyncState
---
## 7. Создание роли в LDAP
### Порядок операций при создании DepartmentRole
```
1. API: POST /api/v1/department-roles/
{"department": 5, "name": "Tech Lead"}
↓
2. DepartmentRoleSerializer.create()
↓
DepartmentRole.objects.create(...)
↓
3. (НЕТ автоматической LDAP-синхронизации)
↓
4. LDAP-группа создаётся при первом назначении роли сотруднику
```
**Ключевая особенность**: Группа роли в LDAP создаётся **лениво** (lazy), при первом назначении.
### Альтернативный подход (если реализован)
```python
# При create роли:
if _is_ldap_enabled():
dept_dn = _get_department_dn(dept)
roles_ou = f"OU=Roles,{dept_dn}"
group_dn = f"CN={role.name},{roles_ou}"
# Создать группу
conn.add(group_dn, object_class=['group'], attributes={
'sAMAccountName': role.name,
'description': f'Role group for {dept.name}'
})
# Сохранить DN
role.ldap_group_dn = group_dn
role.save()
```
---
## 8. Удаление роли из LDAP
### Текущая реализация
**Проблема**: При `DepartmentRole.delete()` LDAP-группа **НЕ удаляется**.
**Последствие**:
- Orphaned groups остаются в LDAP
- Необходима ручная очистка
### Рекомендация
```python
# В DepartmentRole.delete():
def delete(self, *args, **kwargs):
if self.ldap_group_dn and _is_ldap_enabled():
try:
with _ldap() as conn:
conn.delete(self.ldap_group_dn)
except Exception as e:
logger.warning(f"Failed to delete LDAP group: {e}")
super().delete(*args, **kwargs)
```
---
## 9. Проблемные места
### 9.1 Двухфазная логика (if _is_ldap_enabled)
**Проблема**: Во всех API endpoints дублируется логика:
```python
if _is_ldap_enabled():
# Ветка с LDAP
else:
# Ветка без LDAP
```
**Следствие**:
- Усложнение кода
- Две кодовые ветки для тестирования
- Риск рассинхронизации логики
**Решение**: Паттерн Strategy
```python
class RoleBackend(ABC):
@abstractmethod
def set_member_role(self, dept, employee, role): ...
class LdapRoleBackend(RoleBackend):
def set_member_role(self, dept, employee, role):
# ... LDAP логика ...
class DbOnlyRoleBackend(RoleBackend):
def set_member_role(self, dept, employee, role):
# ... DB-only логика ...
# В settings:
if LDAP_ENABLED:
ROLE_BACKEND = LdapRoleBackend()
else:
ROLE_BACKEND = DbOnlyRoleBackend()
```
---
### 9.2 Best Effort LDAP без компенсации
**Проблема**:
```python
try:
# DB update
link.role = role
link.save()
except:
raise # Rollback
try:
# LDAP sync
sync_user_groups_by_cns(...)
except DirectoryServiceError:
pass # Игнорируем!
```
**Последствие**: Несогласованное состояние (роль в БД ≠ группа в LDAP).
**Решение**: Очередь задач для повторных попыток
```python
# После успешного DB update:
if ldap_sync_failed:
schedule_ldap_sync_task.delay(employee_id, role_id)
```
---
### 9.3 Нет валидации существования OU=Roles
**Проблема**: Код предполагает, что `OU=Roles,<dept_dn>` существует.
**Риск**: При создании нового отдела без создания `OU=Roles` синхронизация падает.
**Решение**: Проверка/создание OU при создании отдела
```python
def create_department(dto):
# ...
dept_ou = create_ou(dept.name, DEPARTMENTS_BASE)
roles_ou = create_ou('Roles', dept_ou) # ← гарантировать наличие
# ...
```
---
### 9.4 Отсутствие транзакционности LDAP
**Проблема**: LDAP операции не атомарны.
**Сценарий**:
```python
# Назначение роли:
1. Удалить из группы "Tech Lead" → ✅
2. Добавить в группу "Senior Dev" → ❌ (сбой)
Результат: Сотрудник без ролей в LDAP
```
**Решение**: Two-Phase Commit или Saga pattern
```python
# 1. Prepare phase
old_groups = get_current_groups(user_dn)
new_groups = {role.name}
# 2. Commit phase
try:
for group in old_groups - new_groups:
remove_from_group(user_dn, group)
for group in new_groups - old_groups:
add_to_group(user_dn, group)
except:
# Rollback
for group in old_groups:
add_to_group(user_dn, group)
raise
```
---
### 9.5 Производительность синхронизации
**Проблема**: `sync_user_groups_by_cns()` делает:
1. Поиск всех групп в OU=Roles
2. Проверка членства для каждой группы
3. Modify для каждой изменённой группы
**При назначении роли 100 сотрудникам**: 100 × (search + N×modify) запросов.
**Решение**: Batch operations
```python
def sync_multiple_users_roles(users_roles: List[Tuple[Employee, Role]]):
with _ldap() as conn:
for user, role in users_roles:
# ... подготовка операций ...
# Выполнить все за раз
conn.batch_modify(operations)
```
---
## 10. Итоговая схема LDAP-интеграции
```
┌──────────────────────────────────────────────┐
│ API: POST /departments/{id}/set_member_role/ │
└──────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────┐
│ _is_ldap_enabled()? │
│ No → DB-only branch │
│ Yes → LDAP branch │
└──────────────────────────────────────────────┘
↓ Yes
┌──────────────────────────────────────────────┐
│ DirectoryService.set_member_role() │
└──────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────┐
│ DepartmentService.set_member_role() │
│ 1. DB: EmployeeDepartment.role = new_role │
│ 2. LDAP: sync_user_groups_by_cns() │
└──────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────┐
│ LDAP Operations: │
│ - Get user_dn from LdapSyncState │
│ - Get dept_dn from LdapSyncState │
│ - roles_base = "OU=Roles,<dept_dn>" │
│ - Find all groups in roles_base │
│ - Remove from old role groups │
│ - Add to new role group │
└──────────────────────────────────────────────┘
```
---
**Следующий отчет**: [07_DEPARTMENT_ROLES_TESTS.md](./07_DEPARTMENT_ROLES_TESTS.md) — Покрытие тестами

View File

@ -0,0 +1,659 @@
# Отчет: Тестовое покрытие системы ролей
**Дата**: 30.12.2025
**Анализ**: Тестовые файлы, покрытие функциональности, сценарии
---
## 1. Обзор тестовых файлов
### Основные тестовые файлы
| Файл | Строк | Назначение |
|------|-------|------------|
| **test_department_roles.py** | ~306 | CRUD операций ролей, права, фильтрация |
| **test_department_roles_extra.py** | ~304 | Расширенные сценарии: сортировка, staff override |
| **test_department_membership_separation.py** | ~496 | Разделение add_member vs set_role |
| **test_department_head_rights.py** | ~200 | Права руководителя отдела |
**Общее количество**: ~1300 строк тестового кода для системы ролей.
---
## 2. test_department_roles.py — Основные CRUD
### Расположение
`backend/tests/api/v1/employees/test_department_roles.py`
### Покрываемая функциональность
#### 2.1 Фильтрация по отделу
```python
def test_list_filtered_by_department(api_client: APIClient):
d1 = Department.objects.create(name="Dept A")
d2 = Department.objects.create(name="Dept B")
r1 = make_role(d1, "Engineer", [])
r2 = make_role(d1, "Manager", [])
r3 = make_role(d2, "Sales", [])
# Без фильтра — все 3
resp = api_client.get(url)
assert ids == {r1.id, r2.id, r3.id}
# С фильтром по d1 — только роли d1
resp = api_client.get(url, {"department": d1.id})
assert ids == {r1.id, r2.id}
```
**Проверяет**:
- Query param `?department=<id>` работает
- Без фильтра возвращает все роли
- С фильтром только роли указанного отдела
---
#### 2.2 Права на создание роли
```python
def test_create_requires_assign_department_role(api_client: APIClient):
d = Department.objects.create(name="Dept")
user = make_user("m@example.com")
# Без прав — 403
resp = api_client.post(url, {"department": d.id, "name": "Worker"})
assert resp.status_code == status.HTTP_403_FORBIDDEN
# Выдаём право assign_department_role
grant_assign_in_dept(user, d)
# Теперь можно создать
resp = api_client.post(url, {"department": d.id, "name": "Worker"})
assert resp.status_code == status.HTTP_201_CREATED
```
**Проверяет**:
- `AssignRolePerm()` блокирует без прав
- С правом `assign_department_role` → создание разрешено
- Backward-compat поля присутствуют в ответе
---
#### 2.3 Scope-based права (update/destroy)
```python
def test_update_and_destroy_scope_enforced(api_client: APIClient):
d1 = Department.objects.create(name="Dept1")
d2 = Department.objects.create(name="Dept2")
role_other = make_role(d2, "Other", [])
# Нет прав в d2 → 403
resp = api_client.patch(url_detail_other, {"name": "X"})
assert resp.status_code == status.HTTP_403_FORBIDDEN
# Права в d1 — всё ещё 403 для d2
grant_assign_in_dept(user, d1)
resp = api_client.patch(url_detail_other, {"name": "X"})
assert resp.status_code == status.HTTP_403_FORBIDDEN
# Права в d2 — теперь можно
grant_assign_in_dept(user, d2)
resp = api_client.patch(url_detail_other, {"name": "X"})
assert resp.status_code == status.HTTP_200_OK
```
**Проверяет**:
- Права проверяются по **отделу роли**, а не глобально
- Право в отделе A не даёт доступ к ролям отдела B
- Destroy также требует право в отделе роли
---
#### 2.4 Работа с правами через API
```python
def test_perm_choices_and_perms_and_set_perms(api_client: APIClient):
# 1. perm_choices — справочник
resp = api_client.get(url_choices)
codes = {row["code"] for row in resp.json().get("results", [])}
assert {"manage_department", "assign_department_role"}.issubset(codes)
# 2. set_perms — установка прав по кодам
role = make_role(d, "Engineer", [])
resp = api_client.post(
url_set,
{"permission_codes": ["manage_department", "assign_department_role"]}
)
assert resp.status_code == 200
# 3. Проверка permissions_verbose
verbose = {p["code"] for p in resp.json().get("permissions_verbose", [])}
assert verbose == {"manage_department", "assign_department_role"}
```
**Проверяет**:
- `/perm_choices/` возвращает все доступные права
- `/set_perms/` заменяет права роли
- `permission_codes` работает (альтернатива ID)
- `permissions_verbose` содержит полную информацию
---
## 3. test_department_roles_extra.py — Дополнительные сценарии
### 3.1 Неавторизованный доступ
```python
def test_unauth_cannot_list_or_get():
client = APIClient() # Без force_authenticate
assert client.get(url_list).status_code in (401, 403)
```
**Проверяет**: Неавторизованные запросы блокируются.
---
### 3.2 Read-only без прав
```python
def test_read_only_allowed_without_assign(api_client: APIClient):
r = make_role(d, "Worker", [])
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
# list разрешён
resp = api_client.get(url_list, {"department": d.id})
assert resp.status_code == 200
# retrieve разрешён
assert api_client.get(url_detail).status_code == 200
```
**Проверяет**:
- GET операции (list/retrieve) доступны всем аутентифицированным
- Write операции требуют специальных прав
---
### 3.3 Staff override
```python
def test_staff_override_for_write(api_client: APIClient):
staff = make_user("s@example.com", staff=True)
api_client.force_authenticate(user=staff)
# create без роли в отделе
resp = api_client.post(url_list, {"department": d.id, "name": "SRole"})
assert resp.status_code == 201
# update
assert api_client.patch(url_detail, {"name": "SRole+"}).status_code == 200
# set_perms
assert api_client.post(url_set, {"permission_codes": [...]}).status_code == 200
```
**Проверяет**:
- `user.is_staff` обходит проверки прав
- Staff может управлять любыми ролями в любых отделах
---
### 3.4 Стабильная сортировка
```python
def test_ordering_stable_by_name_then_id(api_client: APIClient):
# Роли с одинаковыми именами в разных отделах
r1 = make_role(d1, "A", [])
r2 = make_role(d2, "A", []) # Одинаковое имя
r3 = make_role(d1, "B", [])
resp = api_client.get(url, {"ordering": "name"})
items = resp.json().get("results", [])
# Сортировка: name, затем id (tie-break)
assert items[0]["name"] == "A" and items[0]["id"] < items[1]["id"]
assert items[2]["name"] == "B"
```
**Проверяет**:
- При одинаковых `name` порядок детерминирован (по `id`)
- `ordering=name` работает корректно
---
## 4. test_department_membership_separation.py — Разделение операций
### Назначение
Проверяет, что `add_member` и `set_member_role` — **разные операции** с разными правами.
---
### 4.1 add_member не назначает роль
```python
def test_add_member_requires_manage_and_does_not_assign_role(api_client):
d = Department.objects.create(name="Dept")
target = make_user("target@example.com")
# 403 с правом assign_department_role (но без manage)
assigner = make_user("assigner@example.com")
grant_assign_in_dept(assigner, d)
resp = api_client.post(url, {"employee_id": target.id})
assert resp.status_code == status.HTTP_403_FORBIDDEN
# 200 с manage_department
manager = make_user("manager@example.com")
grant_manage_in_dept(manager, d)
resp = api_client.post(url, {"employee_id": target.id})
assert resp.status_code == status.HTTP_200_OK
# Роль НЕ назначена
link = EmployeeDepartment.objects.get(employee_id=target.id)
assert link.role_id is None
```
**Проверяет**:
- `add_member` требует `manage_department`, а не `assign_department_role`
- После добавления `role = None`
- Разделение ответственности операций
---
### 4.2 set_member_role требует assign_role
```python
def test_set_member_role_requires_assign_and_works_only_for_members(api_client):
d = Department.objects.create(name="Dept")
target = make_user("target@example.com")
role = make_role(d, "Worker", [])
# add_member сначала
manager = make_user("manager@example.com")
grant_manage_in_dept(manager, d)
api_client.post(url_add_member, {"employee_id": target.id})
# set_member_role с правом assign
assigner = make_user("assigner@example.com")
grant_assign_in_dept(assigner, d)
resp = api_client.post(url_set_role, {
"employee_id": target.id,
"role_id": role.id
})
assert resp.status_code == status.HTTP_200_OK
# Роль назначена
link = EmployeeDepartment.objects.get(employee_id=target.id)
assert link.role_id == role.id
```
**Проверяет**:
- `set_member_role` требует `assign_department_role`
- Можно назначить роль только **существующему** члену отдела
- Роль действительно устанавливается
---
### 4.3 Нельзя назначить роль несуществующему члену
```python
def test_set_member_role_fails_if_not_member(api_client):
target = make_user("target@example.com")
# Не добавляем в отдел!
assigner = make_user("assigner@example.com")
grant_assign_in_dept(assigner, d)
resp = api_client.post(url_set_role, {
"employee_id": target.id,
"role_id": role.id
})
assert resp.status_code == status.HTTP_404_NOT_FOUND
assert "not a member" in resp.json()["detail"].lower()
```
**Проверяет**: 404 если сотрудник не в отделе.
---
### 4.4 Валидация принадлежности роли
```python
def test_set_member_role_fails_if_role_from_different_department(api_client):
d1 = Department.objects.create(name="Dept1")
d2 = Department.objects.create(name="Dept2")
role_d2 = make_role(d2, "Worker", [])
target = make_user("target@example.com")
# Добавляем в d1
add_member(target, d1)
# Пытаемся назначить роль из d2
assigner = make_user("assigner@example.com")
grant_assign_in_dept(assigner, d1)
resp = api_client.post(url_set_role_d1, {
"employee_id": target.id,
"role_id": role_d2.id # Роль из другого отдела!
})
assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert "does not belong" in resp.json()["role_id"][0].lower()
```
**Проверяет**: Нельзя назначить роль из другого отдела.
---
## 5. test_department_head_rights.py — Права руководителя
### 5.1 Руководитель может обновлять отдел
```python
def test_head_can_update_department_without_explicit_role_perms(api_client):
head = make_user("head@example.com")
d = Department.objects.create(name="Dept", head=head)
api_client.force_authenticate(user=head)
resp = api_client.patch(url, {"description": "new"})
assert resp.status_code == status.HTTP_200_OK
```
**Проверяет**:
- Руководителю не нужна роль для управления отделом
- `dept.head == user` → все права автоматически
---
### 5.2 Руководитель может назначать роли
```python
def test_head_can_set_member_role_and_non_head_cannot(api_client):
head = make_user("head@example.com")
d = Department.objects.create(name="Dept", head=head)
worker = make_user("worker@example.com")
add_member(worker, d)
# Руководитель назначает роль
api_client.force_authenticate(user=head)
resp = api_client.post(url, {"employee_id": worker.id, "role_id": role.id})
assert resp.status_code == status.HTTP_200_OK
# Другой пользователь — не может
stranger = make_user("stranger@example.com")
api_client.force_authenticate(user=stranger)
resp = api_client.post(url, {"employee_id": worker.id, "role_id": role.id})
assert resp.status_code == status.HTTP_403_FORBIDDEN
```
**Проверяет**:
- Руководитель имеет право `assign_department_role` автоматически
- Обычный пользователь без роли — запрещено
---
### 5.3 Руководитель теряет права после смены
```python
def test_head_can_change_head_and_loses_rights_afterwards(api_client):
old_head = make_user("old@example.com")
d = Department.objects.create(name="Dept", head=old_head)
new_head = make_user("new@example.com")
# Старый руководитель меняет руководителя
api_client.force_authenticate(user=old_head)
resp = api_client.post(url_set_head, {"head_id": new_head.id})
assert resp.status_code == status.HTTP_200_OK
# Старый больше не глава — нет прав
resp = api_client.patch(url_detail, {"description": "after"})
assert resp.status_code == status.HTTP_403_FORBIDDEN
# У нового руководителя — есть
api_client.force_authenticate(user=new_head)
resp = api_client.patch(url_detail, {"description": "ok"})
assert resp.status_code == status.HTTP_200_OK
```
**Проверяет**:
- Смена руководителя мгновенно лишает старого всех прав
- Новый руководитель сразу получает полный доступ
---
## 6. Вспомогательные функции (Helpers)
### Общие для всех тестов
```python
def make_user(email: str, staff: bool = False) -> User:
"""Создаёт пользователя с уникальным phone_number."""
return User.objects.create_user(
email=email,
password="pwd12345",
phone_number=_unique_phone(),
send_activation_email=False,
)
def make_role(dept: Department, name: str, codes: list[str] | None = None) -> DepartmentRole:
"""Создаёт роль с указанными правами."""
role = DepartmentRole.objects.create(department=dept, name=name)
if codes:
perms = [ensure_dept_perm(c) for c in codes]
role.scoped_permissions.add(*perms)
return role
def grant_assign_in_dept(user: User, dept: Department) -> DepartmentRole:
"""Выдаёт пользователю роль с правом assign_department_role."""
role = make_role(dept, "assigner", ["assign_department_role"])
EmployeeDepartment.objects.update_or_create(
employee=user, department=dept,
defaults={"is_active": True, "role": role},
)
return role
```
### Назначение
- **make_user**: Создание тестовых пользователей с уникальными phone
- **make_role**: Быстрое создание ролей с правами
- **grant_assign_in_dept**: Выдача прав для тестирования
---
## 7. Покрытие функциональности
### Что покрыто ✅
| Функциональность | Тесты | Статус |
|------------------|-------|--------|
| **CRUD ролей** | test_department_roles.py | ✅ Полное |
| **Фильтрация по отделу** | test_list_filtered_by_department | ✅ |
| **Проверка прав create/update/destroy** | test_create_requires_assign, test_update_and_destroy_scope_enforced | ✅ |
| **set_perms API** | test_perm_choices_and_perms_and_set_perms | ✅ |
| **Backward compatibility** | Проверка permissions/permissions_verbose | ✅ |
| **Staff override** | test_staff_override_for_write | ✅ |
| **Сортировка** | test_ordering_stable_by_name_then_id | ✅ |
| **Разделение add_member / set_role** | test_department_membership_separation.py | ✅ Полное |
| **Права руководителя** | test_department_head_rights.py | ✅ Полное |
| **Валидация роли отдела** | test_set_member_role_fails_if_role_from_different_department | ✅ |
---
### Что НЕ покрыто ❌
| Функциональность | Причина |
|------------------|---------|
| **LDAP синхронизация** | Требует mock LDAP сервера |
| **Concurrent updates** | Нет тестов на race conditions |
| **Массовое назначение ролей** | Нет bulk operations |
| **Удаление роли при наличии назначений** | Проверка CASCADE/SET_NULL |
| **Лимиты на количество ролей** | Нет ограничений в коде |
| **Производительность с большим числом ролей** | Нет load tests |
| **Миграция прав при изменении DeptPerm.CHOICES** | Ручной процесс |
---
## 8. Проблемные места в тестах
### 8.1 Дублирование helper функций
**Проблема**: В каждом тестовом файле свои `make_user()`, `make_role()`, `_unique_phone()`.
**Следствие**: При изменении логики нужно обновлять 4 файла.
**Решение**: Вынести в `conftest.py`:
```python
# tests/api/v1/employees/conftest.py
@pytest.fixture
def make_user():
def _make(email, staff=False):
# ... общая логика ...
return _make
```
---
### 8.2 Отсутствие интеграционных тестов
**Проблема**: Тесты проверяют API endpoints, но не полный workflow.
**Пример недостающего теста**:
```python
def test_full_role_assignment_workflow():
# 1. Создать отдел
# 2. Добавить сотрудников
# 3. Создать роль с правами
# 4. Назначить роль сотруднику
# 5. Проверить, что сотрудник может выполнить действие
# 6. Изменить права роли
# 7. Проверить, что изменения применились
# 8. Удалить роль
# 9. Проверить, что сотрудник потерял права
```
---
### 8.3 Нет тестов на граничные случаи
**Примеры**:
- Назначение 1000 ролей одному отделу
- Роль с 0 прав (пустой scoped_permissions)
- Роль с дублирующимся именем (после изменения department)
- Одновременное создание ролей с одинаковым именем (race)
---
### 8.4 Отсутствие тестов на ошибки валидации
**Примеры**:
- `{"scoped_permission_codes": ["nonexistent_code"]}`
- `{"scoped_permissions": [9999999]}` (несуществующий ID)
- `{"name": ""}` (пустое имя)
- `{"name": "A" * 200}` (превышение max_length)
---
## 9. Рекомендации по улучшению тестов
### 9.1 Добавить фабрики (Factory Boy)
```python
import factory
class DepartmentFactory(factory.django.DjangoModelFactory):
class Meta:
model = Department
name = factory.Sequence(lambda n: f"Department {n}")
class DepartmentRoleFactory(factory.django.DjangoModelFactory):
class Meta:
model = DepartmentRole
department = factory.SubFactory(DepartmentFactory)
name = factory.Sequence(lambda n: f"Role {n}")
# Использование:
dept = DepartmentFactory()
role = DepartmentRoleFactory(department=dept)
```
---
### 9.2 Добавить параметризованные тесты
```python
@pytest.mark.parametrize("method,action", [
("post", "create"),
("patch", "update"),
("delete", "destroy"),
])
def test_requires_assign_role_perm(api_client, method, action):
# ... общая логика для всех write операций ...
```
---
### 9.3 Добавить тесты на производительность
```python
def test_list_roles_performance_with_many_roles(api_client):
dept = Department.objects.create(name="Dept")
# Создать 1000 ролей
for i in range(1000):
make_role(dept, f"Role{i}", [])
# Замерить время
import time
start = time.time()
resp = api_client.get(url, {"department": dept.id})
duration = time.time() - start
assert resp.status_code == 200
assert duration < 1.0 # Должно быть быстрее 1 секунды
```
---
### 9.4 Мокировать LDAP для тестов
```python
from unittest.mock import patch
@patch('employees.ldap.directory_service._is_ldap_enabled', return_value=True)
@patch('employees.ldap.directory_service.DirectoryService.set_member_role')
def test_set_member_role_calls_ldap(mock_set_role, mock_enabled, api_client):
# Тест проверяет, что при включённом LDAP вызывается синхронизация
# ...
assert mock_set_role.called
```
---
## 10. Итоговая оценка покрытия
### Метрики
- **Количество тестовых файлов**: 4
- **Количество тестов**: ~20-25
- **Покрытие функциональности**: ~70%
- **Покрытие кода**: (требуется `pytest --cov`)
### Сильные стороны
✅ Хорошее покрытие основных CRUD операций
✅ Проверка системы прав (scoped permissions)
✅ Разделение ответственности (add_member vs set_role)
✅ Проверка прав руководителя
✅ Backward compatibility
### Слабые стороны
❌ Нет LDAP-тестов
❌ Нет интеграционных тестов
❌ Нет граничных случаев
❌ Нет load/performance тестов
❌ Дублирование helpers
---
**Финальный отчет**: [08_DEPARTMENT_ROLES_ARCHITECTURE.md](./08_DEPARTMENT_ROLES_ARCHITECTURE.md) — Архитектура и рекомендации

View File

@ -0,0 +1,824 @@
# Финальный отчет: Архитектура системы ролей и план рефакторинга
**Дата**: 30.12.2025
**Итоговый анализ**: Текущее состояние, проблемы, план полной переработки
---
## 1. Executive Summary
### Текущее состояние
Система ролей отделов представляет собой **двухуровневую модель прав**:
- Django model permissions (глобальные)
- Scoped permissions (в рамках отдела)
**Ключевые компоненты**:
- 3 модели данных (DepartmentRole, DepartmentPermission, EmployeeDepartment)
- 10 типов прав (DeptPerm.CHOICES)
- LDAP-интеграция (опциональная)
- REST API с проверкой прав
- ~70% покрытие тестами
### Основные проблемы
🔴 **Критические**:
1. Двойная логика (if LDAP/else) во всём коде
2. Дублирование функций проверки прав
3. Best-effort LDAP без компенсации ошибок
4. Отсутствие транзакций в критических операциях
🟡 **Важные**:
5. Смешение уровней прав (Django + scoped)
6. Автосинхронизация permissions без миграций
7. Backward-compat поля увеличивают размер ответов
8. Нет кеширования проверок прав
---
## 2. Архитектура: Текущая vs Целевая
### 2.1 Текущая архитектура
```
┌─────────────────────────────────────────────────────────┐
│ API Layer (Views) │
│ - DepartmentRoleViewSet │
│ - DepartmentViewSet.set_member_role() │
│ - Inline: if _is_ldap_enabled() → branch logic │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Permission Checks (Mixed) │
│ - AdminOrDeptAllowed (DRF BasePermission) │
│ - has_dept_perm() — by ID │
│ - user_has_dept_perm() — by object │
│ - Staff/Head shortcuts │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Business Logic (Scattered) │
│ - Serializers (create/update with M2M) │
│ - Utils (_ensure_permissions, _build_links) │
│ - DirectoryService (LDAP sync) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Data Layer (Models) │
│ - DepartmentRole, DepartmentPermission │
│ - EmployeeDepartment (link with role FK) │
│ - LdapSyncState │
└─────────────────────────────────────────────────────────┘
```
**Проблемы**:
- Нет чёткого разделения слоёв
- Бизнес-логика размазана между Views, Serializers, Utils
- LDAP-логика смешана с основной
---
### 2.2 Целевая архитектура (Clean Architecture)
```
┌─────────────────────────────────────────────────────────┐
│ API Layer (Thin) │
│ - DepartmentRoleViewSet │
│ - Маршрутизация → Service Layer │
│ - Валидация input (Serializers) │
│ - Форматирование output │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Service Layer (Use Cases) │
│ - RoleManagementService │
│ • create_role(dept, name, permissions) │
│ • assign_role_to_member(dept, employee, role) │
│ • update_role_permissions(role, permissions) │
│ • delete_role(role) │
│ - Permission checking here │
│ - Transaction boundaries │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Infrastructure Layer (Backends) │
│ - DepartmentRoleBackend (Interface) │
│ ├── LdapRoleBackend (LDAP sync) │
│ └── DbOnlyRoleBackend (DB only) │
│ - Injected via DI │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Domain Layer (Models + Logic) │
│ - DepartmentRole, DepartmentPermission │
│ - EmployeeDepartment │
│ - Domain logic in model methods │
└─────────────────────────────────────────────────────────┘
```
**Преимущества**:
- Чёткое разделение ответственности
- Testable (service layer с mock backends)
- Легко добавить новые backends (Redis, External API)
- Транзакции на уровне use cases
---
## 3. Детальный план рефакторинга
### Фаза 1: Подготовка (1-2 дня)
#### 1.1 Унификация проверки прав
**Текущее состояние**: Две функции (`has_dept_perm` и `user_has_dept_perm`) делают одно и то же.
**Действия**:
```python
# Новая унифицированная функция
def check_dept_permission(
user: User,
department: Department | int,
permission_code: str
) -> bool:
"""Unified permission check supporting both object and ID."""
if isinstance(department, int):
dept_id = department
else:
dept_id = department.id
# Единая логика проверки
if user.is_staff or user.is_superuser:
return True
if Department.objects.filter(id=dept_id, head_id=user.id).exists():
return True
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept_id,
is_active=True,
role__scoped_permissions__code=permission_code
).exists()
# Обратная совместимость
has_dept_perm = check_dept_permission
user_has_dept_perm = lambda u, dept, code: check_dept_permission(u, dept, code)
```
---
#### 1.2 Объединение _ensure и _perm_choices_synced
**Действия**:
```python
def ensure_department_permissions() -> list[dict]:
"""Single source for permission sync."""
# ... логика ...
# Удалить дубликат _perm_choices_synced
# Заменить все вызовы на ensure_department_permissions()
```
---
#### 1.3 Добавить кеширование проверок прав
**Действия**:
```python
from functools import lru_cache
@lru_cache(maxsize=256)
def _check_dept_permission_cached(
user_id: int,
dept_id: int,
permission_code: str
) -> bool:
# ... логика проверки ...
def check_dept_permission(user, department, permission_code):
# Validate inputs
# Call cached version
return _check_dept_permission_cached(user.id, dept_id, permission_code)
```
---
### Фаза 2: Service Layer (3-5 дней)
#### 2.1 Создать RoleManagementService
```python
# employees/services/role_management.py
class RoleManagementService:
def __init__(self, backend: RoleBackend):
self.backend = backend
@transaction.atomic
def create_role(
self,
department: Department,
name: str,
permission_codes: list[str],
requester: User
) -> DepartmentRole:
"""Create a new role with permissions."""
# 1. Check permissions
if not check_dept_permission(requester, department, DeptPerm.ASSIGN_ROLE):
raise PermissionDenied("User lacks assign_role permission")
# 2. Validate permissions exist
perms = DepartmentPermission.objects.filter(code__in=permission_codes)
if perms.count() != len(set(permission_codes)):
raise ValidationError("Some permission codes not found")
# 3. Create role
role = DepartmentRole.objects.create(
department=department,
name=name
)
role.scoped_permissions.set(perms)
# 4. Sync to backend (LDAP/etc)
self.backend.on_role_created(role)
return role
@transaction.atomic
def assign_role(
self,
department: Department,
employee: Employee,
role: DepartmentRole | None,
requester: User
) -> EmployeeDepartment:
"""Assign or remove role from department member."""
# 1. Check permissions
if not check_dept_permission(requester, department, DeptPerm.ASSIGN_ROLE):
raise PermissionDenied()
# 2. Validate member exists
link = EmployeeDepartment.objects.filter(
employee=employee,
department=department
).first()
if not link:
raise NotFound("Employee not a member of department")
# 3. Validate role belongs to department
if role and role.department_id != department.id:
raise ValidationError("Role does not belong to department")
# 4. Update DB
old_role = link.role
link.role = role
link.save(update_fields=['role'])
# 5. Sync to backend
try:
self.backend.on_role_assigned(link, old_role, role)
except BackendError as e:
# Schedule retry
logger.error(f"Backend sync failed: {e}")
schedule_retry_task.delay(link.id)
return link
```
---
#### 2.2 Создать Backend Interface
```python
# employees/backends/base.py
from abc import ABC, abstractmethod
class RoleBackend(ABC):
@abstractmethod
def on_role_created(self, role: DepartmentRole) -> None:
"""Called when a role is created."""
pass
@abstractmethod
def on_role_deleted(self, role: DepartmentRole) -> None:
"""Called when a role is deleted."""
pass
@abstractmethod
def on_role_assigned(
self,
link: EmployeeDepartment,
old_role: DepartmentRole | None,
new_role: DepartmentRole | None
) -> None:
"""Called when a role is assigned/changed."""
pass
```
---
#### 2.3 Реализовать LdapRoleBackend
```python
# employees/backends/ldap_backend.py
class LdapRoleBackend(RoleBackend):
def __init__(self):
self.directory_service = DirectoryService()
def on_role_created(self, role: DepartmentRole) -> None:
"""Create LDAP group for role in OU=Roles."""
dept_dn = self._get_dept_dn(role.department)
roles_ou = f"OU=Roles,{dept_dn}"
group_dn = f"CN={role.name},{roles_ou}"
with _ldap() as conn:
conn.add(group_dn, ['group'], {
'sAMAccountName': role.name,
'description': f'Role: {role.name}'
})
role.ldap_group_dn = group_dn
role.save(update_fields=['ldap_group_dn'])
def on_role_assigned(self, link, old_role, new_role) -> None:
"""Sync user groups in LDAP."""
user_dn = self._get_user_dn(link.employee)
dept_dn = self._get_dept_dn(link.department)
roles_base = f"OU=Roles,{dept_dn}"
target_cns = {new_role.name} if new_role else set()
with _ldap() as conn:
sync_user_groups_by_cns(
conn, user_dn, target_cns,
extra_bases=[roles_base],
do_write=True
)
```
---
#### 2.4 Реализовать DbOnlyBackend
```python
# employees/backends/db_backend.py
class DbOnlyRoleBackend(RoleBackend):
"""No-op backend for DB-only mode."""
def on_role_created(self, role: DepartmentRole) -> None:
pass # Nothing to do
def on_role_deleted(self, role: DepartmentRole) -> None:
pass
def on_role_assigned(self, link, old_role, new_role) -> None:
pass
```
---
#### 2.5 Dependency Injection
```python
# employees/services/__init__.py
def get_role_backend() -> RoleBackend:
"""Factory for role backend based on settings."""
if getattr(settings, 'LDAP_ENABLED', False):
from .backends.ldap_backend import LdapRoleBackend
return LdapRoleBackend()
else:
from .backends.db_backend import DbOnlyRoleBackend
return DbOnlyRoleBackend()
def get_role_service() -> RoleManagementService:
"""Factory for role management service."""
backend = get_role_backend()
return RoleManagementService(backend)
```
---
### Фаза 3: Рефакторинг API (2-3 дня)
#### 3.1 Упростить ViewSet
```python
class DepartmentRoleViewSet(viewsets.ModelViewSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.service = get_role_service()
def create(self, request):
dept_id = request.data.get('department')
dept = get_object_or_404(Department, id=dept_id)
role = self.service.create_role(
department=dept,
name=request.data['name'],
permission_codes=request.data.get('scoped_permission_codes', []),
requester=request.user
)
serializer = self.get_serializer(role)
return Response(serializer.data, status=201)
```
**Преимущества**:
- View становится тонким адаптером
- Вся бизнес-логика в service
- Легко тестировать service отдельно
---
#### 3.2 Упростить Department.set_member_role()
```python
@action(detail=True, methods=["post"])
def set_member_role(self, request, pk=None):
dept = self.get_object()
service = get_role_service()
payload = SetMemberRoleInput(data=request.data)
payload.is_valid(raise_exception=True)
employee = get_object_or_404(Employee, id=payload.validated_data['employee_id'])
role_id = payload.validated_data.get('role_id')
role = get_object_or_404(DepartmentRole, id=role_id) if role_id else None
try:
link = service.assign_role(dept, employee, role, request.user)
return Response({
"employee_id": employee.id,
"role_id": role.id if role else None,
"is_active": link.is_active
})
except (PermissionDenied, NotFound, ValidationError) as e:
return Response({"detail": str(e)}, status=...)
```
---
### Фаза 4: Миграции и очистка (1-2 дня)
#### 4.1 Data Migration для permissions
```python
# Generated migration file
def forward(apps, schema_editor):
DepartmentPermission = apps.get_model('employees', 'DepartmentPermission')
# Sync with DeptPerm.CHOICES
from employees.constants import DeptPerm
for code, name in DeptPerm.CHOICES:
DepartmentPermission.objects.get_or_create(
code=code,
defaults={'name': name}
)
def reverse(apps, schema_editor):
pass # Don't delete
class Migration(migrations.Migration):
dependencies = [...]
operations = [
migrations.RunPython(forward, reverse),
]
```
---
#### 4.2 Удалить Django Model Permissions
```python
# В Department.Meta:
class Meta:
permissions = [] # Удалить все, оставить только scoped
```
**Миграция данных**:
```python
def migrate_permissions(apps, schema_editor):
# Найти все Permission с employees.change_department_head и т.д.
# Создать соответствующие роли с scoped_permissions
# Назначить роли пользователям, у которых были Django permissions
```
---
#### 4.3 Убрать backward-compat поля (breaking change)
```python
class DepartmentRoleSerializer(serializers.ModelSerializer):
# Удалить:
# - permissions (read-only)
# - permissions_verbose (read-only)
class Meta:
fields = ("id", "department", "name", "scoped_permissions")
```
**Версионирование API**: Перенести на `/api/v2/` с чистой схемой.
---
### Фаза 5: Тестирование (2-3 дня)
#### 5.1 Юнит-тесты для Service
```python
def test_create_role_without_permission():
service = RoleManagementService(DbOnlyRoleBackend())
dept = DepartmentFactory()
user = UserFactory() # Без прав
with pytest.raises(PermissionDenied):
service.create_role(dept, "Manager", ["manage_department"], user)
def test_assign_role_syncs_to_backend():
mock_backend = Mock(spec=RoleBackend)
service = RoleManagementService(mock_backend)
link = service.assign_role(dept, employee, role, admin_user)
mock_backend.on_role_assigned.assert_called_once_with(
link, None, role
)
```
---
#### 5.2 Интеграционные тесты
```python
def test_full_role_assignment_workflow(api_client):
# 1. Создать отдел
dept = create_department(...)
# 2. Добавить сотрудников
add_members(dept, [emp1, emp2])
# 3. Создать роль
role = create_role(dept, "Manager", [DeptPerm.MANAGE])
# 4. Назначить роль
assign_role(dept, emp1, role)
# 5. Проверить права
assert check_dept_permission(emp1, dept, DeptPerm.MANAGE)
assert not check_dept_permission(emp2, dept, DeptPerm.MANAGE)
# 6. Изменить права роли
update_role_permissions(role, [DeptPerm.CHANGE_HEAD])
# 7. Проверить изменения
assert not check_dept_permission(emp1, dept, DeptPerm.MANAGE)
assert check_dept_permission(emp1, dept, DeptPerm.CHANGE_HEAD)
```
---
#### 5.3 LDAP-тесты с mock
```python
@patch('employees.backends.ldap_backend._ldap')
def test_ldap_backend_creates_group(mock_ldap):
backend = LdapRoleBackend()
role = DepartmentRoleFactory()
backend.on_role_created(role)
mock_ldap.return_value.__enter__.return_value.add.assert_called_once()
assert role.ldap_group_dn is not None
```
---
## 4. Приоритизация проблем
### Критичность: ВЫСОКАЯ 🔴
| # | Проблема | Риск | Сложность | Приоритет |
|---|----------|------|-----------|-----------|
| 1 | Двойная логика LDAP | Усложнение кода, ошибки | Средняя | **P0** |
| 2 | Best-effort LDAP без retry | Несогласованность данных | Высокая | **P0** |
| 3 | Отсутствие транзакций | Race conditions | Низкая | **P0** |
| 4 | Дублирование функций прав | Техдолг, рассинхронизация | Низкая | **P1** |
### Критичность: СРЕДНЯЯ 🟡
| # | Проблема | Риск | Сложность | Приоритет |
|---|----------|------|-----------|-----------|
| 5 | Смешение уровней прав | Путаница, сложность | Средняя | **P1** |
| 6 | Автосинхронизация permissions | Нет контроля версий | Низкая | **P2** |
| 7 | Backward-compat поля | Большие ответы | Низкая | **P2** |
| 8 | Нет кеширования | Лишние запросы | Низкая | **P2** |
### Критичность: НИЗКАЯ 🟢
| # | Проблема | Риск | Сложность | Приоритет |
|---|----------|------|-----------|-----------|
| 9 | Дублирование helpers в тестах | Техдолг | Низкая | **P3** |
| 10 | Нет LDAP-тестов | Неполное покрытие | Средняя | **P3** |
| 11 | Нет граничных тестов | Скрытые баги | Низкая | **P3** |
---
## 5. Roadmap рефакторинга
### Sprint 1: Фундамент (1-2 недели)
**Цели**:
- Унифицировать проверку прав
- Создать Service Layer
- Реализовать Backend Interface
**Задачи**:
1. ✅ Merge функций has_dept_perm
2. ✅ Создать RoleManagementService
3. ✅ Создать RoleBackend interface
4. ✅ Реализовать LdapRoleBackend и DbOnlyRoleBackend
5. ✅ Добавить DI (get_role_service)
6. ✅ Юнит-тесты для service
**Метрики успеха**:
- Все тесты проходят
- Нет регрессий в функциональности
---
### Sprint 2: Рефакторинг API (1 неделя)
**Цели**:
- Упростить ViewSets
- Убрать if LDAP/else из views
- Добавить транзакции
**Задачи**:
1. ✅ Рефакторинг DepartmentRoleViewSet
2. ✅ Рефакторинг Department.set_member_role
3. ✅ Обновить все endpoints на использование service
4. ✅ Добавить @transaction.atomic
5. ✅ Интеграционные тесты
**Метрики успеха**:
- 0 упоминаний _is_ldap_enabled в views
- Все endpoints работают через service
---
### Sprint 3: Миграции и очистка (1 неделя)
**Цели**:
- Перенести на scoped permissions
- Убрать Django model permissions
- Data migrations
**Задачи**:
1. ✅ Data migration для DepartmentPermission
2. ✅ Миграция пользователей с Django perms на роли
3. ✅ Удалить Department.Meta.permissions
4. ✅ Обновить тесты
5. ✅ Обновить документацию
**Метрики успеха**:
- Все права через scoped permissions
- Миграции проходят без ошибок
---
### Sprint 4: Оптимизация (1 неделя)
**Цели**:
- Добавить кеширование
- Убрать backward-compat
- Улучшить производительность
**Задачи**:
1. ✅ Кеширование check_dept_permission
2. ✅ Версионирование API (v2)
3. ✅ Удалить permissions/permissions_verbose
4. ✅ Batch LDAP operations
5. ✅ Load tests
**Метрики успеха**:
- Скорость проверки прав +50%
- Размер ответов API -30%
---
## 6. Метрики качества
### До рефакторинга
| Метрика | Значение |
|---------|----------|
| Cyclomatic complexity (views.py) | ~15 |
| Lines of code (role logic) | ~2000 |
| Test coverage | ~70% |
| API response size | ~5KB (with backward-compat) |
| Permission check time | ~50ms (without cache) |
| LDAP sync failures | ~5% (no retry) |
### После рефакторинга (цель)
| Метрика | Значение |
|---------|----------|
| Cyclomatic complexity | ~8 |
| Lines of code | ~1500 |
| Test coverage | ~90% |
| API response size | ~3KB (v2 API) |
| Permission check time | ~5ms (with cache) |
| LDAP sync failures | <1% (with retry queue) |
---
## 7. Риски и митигация
### Риск 1: Breaking changes для фронтенда
**Описание**: Удаление backward-compat полей сломает старый фронт.
**Митигация**:
- Версионирование API (v1 → v2)
- Поддержка v1 ещё 6 месяцев
- Постепенная миграция фронта
---
### Риск 2: LDAP-синхронизация упадёт при рефакторинге
**Описание**: Изменение логики может нарушить синхронизацию.
**Митигация**:
- Feature flag для включения нового backend
- Параллельное тестирование old/new
- Rollback plan
---
### Риск 3: Производительность ухудшится
**Описание**: Service layer добавляет overhead.
**Митигация**:
- Benchmarks до/после
- Кеширование на уровне service
- Профилирование (django-silk)
---
## 8. Итоговые рекомендации
### Немедленные действия (P0)
1. **Добавить @transaction.atomic** к всем write операциям
2. **Реализовать retry queue** для LDAP-синхронизации
3. **Унифицировать has_dept_perm** функции
### Краткосрочные (1-2 месяца)
1. Создать Service Layer
2. Реализовать Backend pattern
3. Рефакторить ViewSets
### Долгосрочные (3-6 месяцев)
1. Миграция на scoped permissions
2. Версионирование API (v2)
3. Полная оптимизация
---
## 9. Заключение
Система ролей отделов имеет **solid foundation**, но страдает от **архитектурных проблем**, накопленных со временем. Основные направления улучшения:
1. ✅ **Разделение ответственности** — Service Layer + Backend Pattern
2. ✅ **Упрощение кода** — убрать if LDAP/else
3. ✅ **Улучшение надёжности** — транзакции, retry, компенсации
4. ✅ **Оптимизация** — кеширование, batch operations
При последовательной реализации плана **за 4-6 недель** можно достичь:
- 📉 Снижение сложности кода на 40%
- 📈 Увеличение покрытия тестами до 90%
- ⚡ Ускорение проверки прав в 10 раз
- 🛡️ Повышение надёжности LDAP-синхронизации в 5 раз
**Рекомендация**: Начать с P0 задач (транзакции, retry), затем постепенно внедрять Service Layer без breaking changes для фронтенда.
---
**Все отчеты:**
- [01_DEPARTMENT_ROLES_MODELS.md](./01_DEPARTMENT_ROLES_MODELS.md)
- [02_DEPARTMENT_ROLES_API.md](./02_DEPARTMENT_ROLES_API.md)
- [03_DEPARTMENT_ROLES_SERIALIZERS.md](./03_DEPARTMENT_ROLES_SERIALIZERS.md)
- [04_DEPARTMENT_ROLES_PERMISSIONS.md](./04_DEPARTMENT_ROLES_PERMISSIONS.md)
- [05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md](./05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md)
- [06_DEPARTMENT_ROLES_LDAP.md](./06_DEPARTMENT_ROLES_LDAP.md)
- [07_DEPARTMENT_ROLES_TESTS.md](./07_DEPARTMENT_ROLES_TESTS.md)
- [08_DEPARTMENT_ROLES_ARCHITECTURE.md](./08_DEPARTMENT_ROLES_ARCHITECTURE.md) ← **Вы здесь**

View File

@ -0,0 +1,817 @@
# Редизайн LDAP-архитектуры для ролей отделов
**Дата**: 30.12.2025
**Статус**: Проектирование
**Проблема**: OU=Roles не работает с групповыми политиками
---
## 1. Текущая проблема
### 1.1 Структура сейчас
```
DC=company,DC=local
└── OU=Departments
└── OU=IT Department ← Сотрудники здесь (CN=John Doe)
├── CN=John Doe ← Пользователь
├── CN=Jane Smith ← Пользователь
├── CN=IT Department ← Группа отдела (все члены)
└── OU=Roles ← Подпапка для ролей
├── CN=Tech Lead ← Группа роли
└── CN=DevOps ← Группа роли
```
### 1.2 Почему не работает
**Проблема с Group Policy (GPO):**
- GPO применяются к объектам **по их расположению в дереве LDAP**
- Пользователи `CN=John Doe` находятся в `OU=IT Department`
- Группы ролей находятся в `OU=Roles,OU=IT Department`
- **Вложенный OU не влияет на policy применяемые к родительскому OU**
**Иллюстрация:**
```
GPO → OU=IT Department (включает John Doe, Jane Smith)
└── OU=Roles (НЕ включает пользователей! Только группы)
└── CN=Tech Lead (группа, без GPO на неё)
└── member: CN=John Doe (но John в родительском OU)
```
**Результат**: Членство в группе `CN=Tech Lead,OU=Roles,...` не даёт применения GPO, потому что:
1. GPO применяется к OU, а не к группам напрямую
2. Пользователи лежат выше, в `OU=IT Department`, а не в `OU=Roles`
### 1.3 Дополнительная проблема
**Текущая логика**: Роли можно назначить только членам отдела.
**Новое требование**: Роли должны быть доступны **любому сотруднику компании** (не обязательно члену отдела).
---
## 2. Новая архитектура
### 2.1 Убираем OU=Roles
**Новая структура:**
```
DC=company,DC=local
└── OU=Departments
└── OU=IT Department
├── CN=John Doe ← Пользователь
├── CN=Jane Smith ← Пользователь
├── CN=DEP_IT Department ← Группа отдела (члены)
├── CN=ROLE_TechLead ← Группа роли (прямо в OU отдела)
└── CN=ROLE_DevOps ← Группа роли (прямо в OU отдела)
```
### 2.2 Преимущества
1. **GPO работают**: Группы ролей в том же OU, что и пользователи
2. **Проще структура**: Нет вложенных OU
3. **Унификация**: Отдел и роли управляются одинаково
4. **Гибкость**: Можно добавлять пользователей из других отделов
### 2.3 Именование групп — конвенция проекта
**Существующая конвенция:**
- `DEP_<Name>` — группы отделов (например, `DEP_IT`, `DEP_HR`)
- `POS_<Name>` — группы должностей (например, `POS_Developer`, `POS_Manager`)
**Для ролей:**
- `ROLE_<Name>` — группы ролей (например, `ROLE_TechLead`, `ROLE_DevOps`)
**Примеры:**
```
CN=DEP_IT,OU=IT Department,... ← группа членов отдела
CN=ROLE_TechLead,OU=IT Department,... ← группа роли
CN=ROLE_DevOps,OU=IT Department,... ← группа роли
```
**Почему такой формат?**
- Единообразие с DEP_ и POS_
- Легко фильтровать: `(cn=ROLE_*)`
- Роли уникальны в рамках отдела (unique_together)
- Группа лежит в OU отдела → контекст понятен из расположения
---
## 3. Изменения в моделях Django
### 3.1 DepartmentRole
**Текущее поле:**
```python
ldap_group_dn = models.CharField(
"DN агрегаторной группы роли в AD (ROLE_*)",
max_length=512,
blank=True,
default="",
)
```
**Без изменений** — поле уже существует и подходит.
### 3.2 EmployeeDepartment — ослабление связи
**Текущее ограничение:**
```python
class EmployeeDepartment(models.Model):
employee = models.ForeignKey("Employee", ...)
department = models.ForeignKey(Department, ...)
role = models.ForeignKey(DepartmentRole, null=True, blank=True, ...)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["department_id", "employee_id"],
name="uniq_employee_per_department",
),
]
```
**Проблема**: Роль можно назначить только через `EmployeeDepartment`, т.е. сотрудник должен быть членом отдела.
### 3.3 Новая модель: RoleAssignment
**Предлагаемое решение**: Создать отдельную модель для назначений ролей.
```python
class RoleAssignment(models.Model):
"""Назначение роли сотруднику (не обязательно члену отдела)."""
employee = models.ForeignKey(
"Employee",
on_delete=models.CASCADE,
related_name="role_assignments"
)
role = models.ForeignKey(
DepartmentRole,
on_delete=models.CASCADE,
related_name="assignments"
)
assigned_by = models.ForeignKey(
"Employee",
null=True,
on_delete=models.SET_NULL,
related_name="assigned_roles"
)
assigned_at = models.DateTimeField(auto_now_add=True)
is_active = models.BooleanField(default=True)
class Meta:
verbose_name = "Назначение роли"
verbose_name_plural = "Назначения ролей"
constraints = [
models.UniqueConstraint(
fields=["employee", "role"],
name="uniq_employee_role",
),
]
indexes = [
models.Index(fields=["role", "is_active"]),
models.Index(fields=["employee", "is_active"]),
]
def __str__(self):
return f"{self.employee} → {self.role}"
```
### 3.4 Миграция данных
```python
# Миграция: employees/migrations/XXXX_create_role_assignment.py
def migrate_role_assignments(apps, schema_editor):
"""Перенос role из EmployeeDepartment в RoleAssignment."""
EmployeeDepartment = apps.get_model('employees', 'EmployeeDepartment')
RoleAssignment = apps.get_model('employees', 'RoleAssignment')
for link in EmployeeDepartment.objects.filter(role__isnull=False):
RoleAssignment.objects.get_or_create(
employee=link.employee,
role=link.role,
defaults={
'is_active': link.is_active,
}
)
```
### 3.5 Обратная совместимость
**Переходный период:**
- Оставить `EmployeeDepartment.role` (deprecated)
- Добавить property для чтения из `RoleAssignment`
```python
class EmployeeDepartment(models.Model):
# Deprecated: использовать RoleAssignment
role = models.ForeignKey(
DepartmentRole,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="members_legacy",
help_text="DEPRECATED: Использовать RoleAssignment",
)
```
---
## 4. Изменения LDAP-логики
### 4.1 Удалить создание OU=Roles
**Файл**: `employees/ldap/services/department_service.py`
**Текущий код (строки 651-655):**
```python
def _ensure_department_ou(self, conn: Connection, name: str) -> str:
# ... создание OU отдела ...
conn.add(f"OU=Roles,{dn}", ["top", "organizationalUnit"]) # УДАЛИТЬ
return dn
```
**Новый код:**
```python
def _ensure_department_ou(self, conn: Connection, name: str) -> str:
"""Гарантирует наличие OU отдела (без вложенного OU=Roles)."""
base = getattr(settings, "LDAP_DEPARTMENTS_BASE", None)
if not base:
raise RuntimeError("LDAP_DEPARTMENTS_BASE is not configured")
dn = f"OU={name},{base}"
ok = conn.search(dn, "(objectClass=organizationalUnit)", search_scope=BASE)
if ok and conn.entries:
return dn
ok = conn.add(dn, ["top", "organizationalUnit"])
if not ok:
raise RuntimeError(f"LDAP add OU failed: {conn.result}")
# Больше не создаём OU=Roles
return dn
```
### 4.2 Создание группы роли
**Новый метод:**
```python
def create_role_group(self, role: DepartmentRole) -> str:
"""Создаёт группу для роли в OU отдела.
Args:
role: Роль отдела.
Returns:
str: DN созданной группы.
Raises:
DirectoryLdapError: Ошибка создания группы.
"""
dept = role.department
dept_dn = self._get_department_dn(dept)
# Формат: ROLE_<RoleName> (аналогично DEP_, POS_)
role_name = self._sanitize_name(role.name)
cn = f"ROLE_{role_name}"
group_dn = f"CN={cn},{dept_dn}"
with _ldap() as conn:
attrs = {
"sAMAccountName": cn,
"description": f"Role: {role.name} in {dept.name}",
"groupType": group_type("global", security_enabled=True),
}
ok = conn.add(group_dn, ["top", "group"], attrs)
if not ok:
if "entryAlreadyExists" in str(conn.result):
return group_dn # Уже существует
raise DirectoryLdapError(f"LDAP create role group failed: {conn.result}")
# Сохранить DN в модели
role.ldap_group_dn = group_dn
role.save(update_fields=["ldap_group_dn"])
return group_dn
def _sanitize_name(self, name: str) -> str:
"""Очищает имя для использования в CN."""
import re
# Убираем спецсимволы, заменяем пробелы на _
clean = re.sub(r'[^\w\s-]', '', name)
clean = re.sub(r'\s+', '_', clean)
return clean[:50] # Ограничение длины
```
### 4.3 Переименование группы роли
```python
def rename_role_group(self, role: DepartmentRole, new_name: str) -> str:
"""Переименовывает группу роли в LDAP.
Args:
role: Роль с текущим ldap_group_dn.
new_name: Новое название роли.
Returns:
str: Новый DN группы.
"""
if not role.ldap_group_dn:
# Группы нет — создаём
return self.create_role_group(role)
# Формат: ROLE_<RoleName>
new_role_name = self._sanitize_name(new_name)
new_cn = f"ROLE_{new_role_name}"
with _ldap() as conn:
new_dn = self._group_service.rename(conn, role.ldap_group_dn, new_cn=new_cn)
role.ldap_group_dn = new_dn
role.save(update_fields=["ldap_group_dn"])
return new_dn
```
### 4.4 Удаление группы роли
```python
def delete_role_group(self, role: DepartmentRole) -> None:
"""Удаляет группу роли из LDAP.
Args:
role: Роль для удаления группы.
"""
if not role.ldap_group_dn:
return
with _ldap() as conn:
try:
self._group_service.delete(conn, role.ldap_group_dn)
except Exception as e:
# Best effort — логируем, но не падаем
logger.warning(f"Failed to delete role group {role.ldap_group_dn}: {e}")
```
### 4.5 Назначение роли сотруднику (новая логика)
**Текущий код** (`set_member_role`, строки 540-575):
```python
def set_member_role(self, dept, employee, role):
# 1. Обновить EmployeeDepartment.role
link = EmployeeDepartment.objects.get(employee_id=employee.id, department_id=dept.id)
link.role = role
link.save()
# 2. Синхронизировать группы в OU=Roles
if role:
roles_base = f"OU=Roles,{dept_dn}" # ← ПРОБЛЕМА: OU=Roles
sync_user_groups_by_cns(conn, user_dn, {role.name}, extra_bases=[roles_base])
```
**Новый код:**
```python
def assign_role(
self,
employee: Employee,
role: DepartmentRole,
assigned_by: Employee | None = None
) -> RoleAssignment:
"""Назначает роль сотруднику (не требует членства в отделе).
Args:
employee: Сотрудник.
role: Роль для назначения.
assigned_by: Кто назначил (опционально).
Returns:
RoleAssignment: Созданное назначение.
Raises:
DirectoryDbError: Ошибка БД.
DirectoryLdapError: Ошибка LDAP.
"""
# 1. Создать/обновить назначение в БД
try:
with transaction.atomic():
assignment, created = RoleAssignment.objects.update_or_create(
employee=employee,
role=role,
defaults={
"is_active": True,
"assigned_by": assigned_by,
}
)
except Exception as e:
raise DirectoryDbError(str(e)) from e
# 2. Добавить в LDAP-группу роли
try:
self._sync_role_membership(employee, role, add=True)
except Exception as e:
logger.error(f"LDAP role sync failed: {e}")
# Не откатываем БД — best effort
return assignment
def revoke_role(
self,
employee: Employee,
role: DepartmentRole
) -> None:
"""Отзывает роль у сотрудника.
Args:
employee: Сотрудник.
role: Роль для отзыва.
"""
# 1. Деактивировать/удалить назначение
RoleAssignment.objects.filter(
employee=employee,
role=role
).update(is_active=False)
# 2. Удалить из LDAP-группы
try:
self._sync_role_membership(employee, role, add=False)
except Exception as e:
logger.error(f"LDAP role revoke failed: {e}")
def _sync_role_membership(
self,
employee: Employee,
role: DepartmentRole,
add: bool = True
) -> None:
"""Синхронизирует членство в группе роли LDAP.
Args:
employee: Сотрудник.
role: Роль.
add: True — добавить, False — удалить.
"""
if not role.ldap_group_dn:
# Создаём группу если нет
self.create_role_group(role)
if not role.ldap_group_dn:
return # Не удалось создать
user_dn = self._user_service._get_employee_dn(employee)
with _ldap() as conn:
if add:
self._group_service.add_members(conn, role.ldap_group_dn, [user_dn])
else:
self._group_service.remove_members(conn, role.ldap_group_dn, [user_dn])
```
### 4.6 Удалить ссылки на OU=Roles
**Файлы для изменения:**
1. `employees/ldap/services/department_service.py`:
- Удалить `conn.add(f"OU=Roles,{dn}", ...)` (строка 655)
- Заменить `roles_base = f"OU=Roles,{dept_dn}"` на `role.ldap_group_dn`
2. `employees/ldap/sync_service.py`:
- Удалить `extra_bases.append(f"OU=Roles,OU={dept_for_roles},{dept_base}")` (строка 305)
3. `employees/ldap/utils/group_utils.py`:
- Убрать специальную логику для `"OU=Roles," in dn` (строка 127)
---
## 5. Изменения API
### 5.1 Новый endpoint: assign_role
**URL**: `POST /api/v1/department-roles/{role_id}/assign/`
**Payload**:
```json
{
"employee_id": 42
}
```
**Response** (201):
```json
{
"id": 1,
"employee_id": 42,
"role_id": 15,
"assigned_at": "2025-12-30T10:00:00Z",
"is_active": true
}
```
**Permission**: `DeptPerm.ASSIGN_ROLE` в отделе роли.
### 5.2 Новый endpoint: revoke_role
**URL**: `POST /api/v1/department-roles/{role_id}/revoke/`
**Payload**:
```json
{
"employee_id": 42
}
```
**Response** (204): No Content
### 5.3 Список назначений роли
**URL**: `GET /api/v1/department-roles/{role_id}/assignments/`
**Response**:
```json
{
"count": 3,
"results": [
{
"id": 1,
"employee_id": 42,
"employee_name": "John Doe",
"assigned_at": "2025-12-30T10:00:00Z",
"assigned_by": 1,
"is_active": true
}
]
}
```
### 5.4 Deprecated: set_member_role
**Статус**: Deprecated, но поддерживается для обратной совместимости.
**Изменение**: Внутренне вызывает `assign_role`/`revoke_role`.
```python
@action(detail=True, methods=["post"])
def set_member_role(self, request, pk=None):
"""DEPRECATED: Use /department-roles/{id}/assign/ instead."""
warnings.warn(
"set_member_role is deprecated, use /department-roles/{id}/assign/",
DeprecationWarning
)
dept = self.get_object()
payload = SetMemberRoleInput(data=request.data)
payload.is_valid(raise_exception=True)
employee_id = payload.validated_data["employee_id"]
role_id = payload.validated_data.get("role_id")
employee = get_object_or_404(Employee, id=employee_id)
if role_id:
role = get_object_or_404(DepartmentRole, id=role_id, department=dept)
self.service.assign_role(employee, role, request.user.employee)
else:
# Снять все роли в этом отделе
for assignment in RoleAssignment.objects.filter(
employee=employee,
role__department=dept,
is_active=True
):
self.service.revoke_role(employee, assignment.role)
return Response(...)
```
---
## 6. Миграция существующих данных LDAP
### 6.1 Скрипт миграции
```python
# management/commands/migrate_role_groups.py
from django.core.management.base import BaseCommand
from employees.models import DepartmentRole, Department
from employees.ldap.infrastructure.connections import _ldap
class Command(BaseCommand):
help = "Migrate role groups from OU=Roles to department OU"
def handle(self, *args, **options):
with _ldap() as conn:
for role in DepartmentRole.objects.select_related('department').all():
old_dn = role.ldap_group_dn
if not old_dn or "OU=Roles" not in old_dn:
self.stdout.write(f"Skipping {role}: no OU=Roles in DN")
continue
# Получить новый DN (в OU отдела, без OU=Roles)
dept_dn = self._get_dept_dn(role.department)
role_name = self._sanitize(role.name)
new_cn = f"ROLE_{role_name}" # Формат: ROLE_<RoleName>
new_dn = f"CN={new_cn},{dept_dn}"
# Переместить группу
try:
if conn.search(old_dn, "(objectClass=group)", search_scope=BASE):
# Перемещение в LDAP
conn.modify_dn(
old_dn,
f"CN={new_cn}",
new_superior=dept_dn
)
self.stdout.write(self.style.SUCCESS(
f"Moved {old_dn} -> {new_dn}"
))
else:
# Группы нет — создать новую
# ...
except Exception as e:
self.stderr.write(f"Error moving {old_dn}: {e}")
# Обновить модель
role.ldap_group_dn = new_dn
role.save(update_fields=["ldap_group_dn"])
```
### 6.2 Удаление пустых OU=Roles
```python
def cleanup_empty_roles_ou(self):
"""Удаляет пустые OU=Roles после миграции."""
with _ldap() as conn:
base = settings.LDAP_DEPARTMENTS_BASE
conn.search(
base,
"(objectClass=organizationalUnit)",
attributes=["distinguishedName"]
)
for entry in conn.entries:
dn = entry.entry_dn
if "OU=Roles" not in dn:
continue
# Проверить пустой ли
conn.search(dn, "(objectClass=*)", search_scope=LEVEL)
if not conn.entries:
conn.delete(dn)
print(f"Deleted empty OU: {dn}")
```
---
## 7. Проверка прав — изменения
### 7.1 Текущая логика
```python
def has_dept_perm(user_id: int, dept_id: int, permission_code: str) -> bool:
"""Проверяет наличие права через EmployeeDepartment.role."""
return EmployeeDepartment.objects.filter(
employee_id=user_id,
department_id=dept_id,
is_active=True,
role__scoped_permissions__code=permission_code
).exists()
```
### 7.2 Новая логика
```python
def has_dept_perm(user_id: int, dept_id: int, permission_code: str) -> bool:
"""Проверяет наличие права через RoleAssignment."""
# Быстрые проверки
user = User.objects.filter(id=user_id).first()
if not user:
return False
if user.is_staff or user.is_superuser:
return True
# Руководитель отдела
if Department.objects.filter(id=dept_id, head__user_id=user_id).exists():
return True
# Через назначение роли (новая модель)
return RoleAssignment.objects.filter(
employee__user_id=user_id,
role__department_id=dept_id,
role__scoped_permissions__code=permission_code,
is_active=True
).exists()
```
---
## 8. План внедрения
### Этап 1: Подготовка (1 день)
1. ✅ Создать модель `RoleAssignment`
2. ✅ Написать миграции
3. ✅ Добавить миграцию данных из `EmployeeDepartment.role`
### Этап 2: LDAP-логика (2 дня)
1. ✅ Добавить методы `create_role_group`, `rename_role_group`, `delete_role_group`
2. ✅ Изменить `_ensure_department_ou` — убрать создание OU=Roles
3. ✅ Реализовать `assign_role`, `revoke_role` с LDAP-синхронизацией
4. ✅ Написать скрипт миграции групп из OU=Roles
### Этап 3: API (1 день)
1. ✅ Добавить endpoints `/assign/`, `/revoke/`, `/assignments/`
2. ✅ Deprecate `set_member_role` с поддержкой обратной совместимости
3. ✅ Обновить сериализаторы
### Этап 4: Permissions (0.5 дня)
1. ✅ Обновить `has_dept_perm` для использования `RoleAssignment`
2. ✅ Обновить `AdminOrDeptAllowed`
### Этап 5: Тестирование (2 дня)
1. ✅ Unit-тесты для новых методов
2. ✅ Integration-тесты LDAP (с mock)
3. ✅ Миграция на тестовом AD
### Этап 6: Продакшн миграция (1 день)
1. ✅ Backup LDAP и БД
2. ✅ Запуск миграции Django
3. ✅ Запуск скрипта миграции LDAP-групп
4. ✅ Cleanup пустых OU=Roles
5. ✅ Мониторинг и откат при проблемах
---
## 9. Риски и митигация
### Риск 1: Конфликт имён групп
**Описание**: Группа `ROLE_IT_TechLead` уже существует (например, создана вручную).
**Митигация**:
- Проверять существование перед созданием
- При конфликте — добавлять суффикс: `ROLE_IT_TechLead_2`
### Риск 2: Ошибки миграции LDAP
**Описание**: Перемещение групп может не сработать (права, блокировки).
**Митигация**:
- Запуск в maintenance window
- Fallback: создать новую группу + скопировать members
- Детальное логирование
### Риск 3: Нарушение обратной совместимости
**Описание**: Старый код использует `EmployeeDepartment.role`.
**Митигация**:
- Оставить поле `role` (deprecated)
- Добавить signal для синхронизации с `RoleAssignment`
- Постепенная миграция кода
---
## 10. Итоговая схема
```
┌────────────────────────────────────────────────────────────┐
│ Django Models │
├────────────────────────────────────────────────────────────┤
│ Department │
│ └── DepartmentRole (roles) │
│ └── RoleAssignment (assignments) ← НОВОЕ │
│ └── Employee (любой сотрудник) │
│ │
│ EmployeeDepartment (членство в отделе, БЕЗ роли) │
└────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────┐
│ LDAP Structure │
├────────────────────────────────────────────────────────────┤
│ OU=Departments │
│ └── OU=IT Department │
│ ├── CN=John Doe (пользователь) │
│ ├── CN=DEP_IT Department (группа членов) │
│ ├── CN=ROLE_TechLead (группа роли) ← В OU! │
│ └── CN=ROLE_DevOps (группа роли) │
│ │
│ [УДАЛЕНО: OU=Roles] │
└────────────────────────────────────────────────────────────┘
```
**Конвенция именования групп:**
- `DEP_<Name>` — группы отделов
- `POS_<Name>` — группы должностей
- `ROLE_<Name>` — группы ролей ← НОВОЕ
---
**Связанные отчёты:**
- [01_DEPARTMENT_ROLES_MODELS.md](./01_DEPARTMENT_ROLES_MODELS.md)
- [06_DEPARTMENT_ROLES_LDAP.md](./06_DEPARTMENT_ROLES_LDAP.md)
- [08_DEPARTMENT_ROLES_ARCHITECTURE.md](./08_DEPARTMENT_ROLES_ARCHITECTURE.md)

View File

@ -13,7 +13,7 @@ from django.db import transaction
from django.utils import timezone
from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, Connection
from ...models import Department, Employee, EmployeeDepartment, LdapSyncState
from ...models import Department, DepartmentRole, Employee, EmployeeDepartment, LdapSyncState, RoleAssignment
from ..domain.dtos import DirectoryDepartmentDTO
from ..errors import (
DirectoryDbError,
@ -531,7 +531,10 @@ class DepartmentService:
def set_member_role(
self, dept: Department, employee: Employee, role
) -> None:
"""Меняет роль участника отдела с синхронизацией LDAP-групп Roles.
"""DEPRECATED: Используйте assign_role / revoke_role.
Меняет роль участника отдела с синхронизацией LDAP-групп.
Сохранена для обратной совместимости.
Args:
dept: Отдел.
@ -542,11 +545,13 @@ class DepartmentService:
DirectoryDbError: Ошибка при обновлении БД.
DirectoryLdapError: Ошибка при синхронизации групп.
"""
# Обновляем EmployeeDepartment.role для обратной совместимости
try:
with transaction.atomic():
link = EmployeeDepartment.objects.get(
employee_id=employee.id, department_id=dept.id
)
old_role = link.role
link.role = role
link.save(update_fields=["role"])
except EmployeeDepartment.DoesNotExist as e:
@ -556,25 +561,231 @@ class DepartmentService:
except Exception as e:
raise DirectoryDbError(str(e)) from e
# Обновляем RoleAssignment
if role:
self.assign_role(employee, role, assigned_by=None)
elif old_role:
self.revoke_role(employee, old_role)
def assign_role(
self,
employee: Employee,
role: DepartmentRole,
assigned_by: Optional[Employee] = None,
) -> RoleAssignment:
"""Назначает роль сотруднику (не требует членства в отделе).
Args:
employee: Сотрудник.
role: Роль для назначения.
assigned_by: Кто назначил (опционально).
Returns:
RoleAssignment: Созданное/обновлённое назначение.
Raises:
DirectoryDbError: Ошибка БД.
DirectoryLdapError: Ошибка LDAP.
"""
# 1. Создать/обновить назначение в БД
try:
if role:
user_dn = self._user_service._get_employee_dn(employee)
dept_dn = self._get_department_dn(dept)
roles_base = f"OU=Roles,{dept_dn}"
with _ldap() as conn:
sync_user_groups_by_cns(
conn,
user_dn,
{role.name},
extra_bases=[roles_base],
do_write=True,
)
except DirectoryServiceError:
pass
with transaction.atomic():
assignment, created = RoleAssignment.objects.update_or_create(
employee=employee,
role=role,
defaults={
"is_active": True,
"assigned_by": assigned_by,
}
)
except Exception as e:
raise DirectoryLdapError(
f"LDAP role sync failed: {e}"
) from e
raise DirectoryDbError(str(e)) from e
# 2. Синхронизировать LDAP-группу роли
try:
self._sync_role_membership(employee, role, add=True)
except DirectoryServiceError:
pass # Best effort — LDAP-ошибки не прерывают операцию
except Exception as e:
raise DirectoryLdapError(f"LDAP role sync failed: {e}") from e
return assignment
def revoke_role(
self,
employee: Employee,
role: DepartmentRole,
) -> None:
"""Отзывает роль у сотрудника.
Args:
employee: Сотрудник.
role: Роль для отзыва.
"""
# 1. Деактивировать назначение
RoleAssignment.objects.filter(
employee=employee,
role=role
).update(is_active=False)
# 2. Удалить из LDAP-группы
try:
self._sync_role_membership(employee, role, add=False)
except Exception:
pass # Best effort
def _sync_role_membership(
self,
employee: Employee,
role: DepartmentRole,
add: bool = True,
) -> None:
"""Синхронизирует членство сотрудника в группе роли LDAP.
Args:
employee: Сотрудник.
role: Роль.
add: True — добавить в группу, False — удалить.
"""
# Создаём группу роли если нет
if not role.ldap_group_dn:
self._ensure_role_group(role)
if not role.ldap_group_dn:
return # Не удалось создать группу
user_dn = self._user_service._get_employee_dn(employee)
with _ldap() as conn:
if add:
self._group_service.add_members(conn, role.ldap_group_dn, [user_dn])
else:
self._group_service.remove_members(conn, role.ldap_group_dn, [user_dn])
def _ensure_role_group(self, role: DepartmentRole) -> str:
"""Гарантирует наличие группы роли ROLE_<Name> в OU отдела.
Args:
role: Роль отдела.
Returns:
str: DN группы роли.
"""
dept = role.department
dept_dn = self._get_department_dn(dept)
# Формат: ROLE_<RoleName> (аналогично DEP_, POS_)
role_name = self._sanitize_name(role.name)
expected_cn = f"ROLE_{role_name}"
expected_rdn = f"CN={esc_rdn(expected_cn)}"
saved_dn = (role.ldap_group_dn or "").strip()
with _ldap() as conn:
# Проверяем существующую группу
if saved_dn:
ok = conn.search(
saved_dn,
"(objectClass=group)",
search_scope=BASE,
attributes=["distinguishedName"],
)
if ok and conn.entries:
# Группа существует — проверяем нужно ли переименовать
cur_rdn = saved_dn.split(",", 1)[0]
if cur_rdn != expected_rdn:
# Переименовываем
if not conn.modify_dn(saved_dn, expected_rdn):
raise RuntimeError(
f"LDAP rename role group failed: {conn.result}"
)
base = ",".join(saved_dn.split(",")[1:])
new_dn = f"{expected_rdn},{base}"
else:
new_dn = saved_dn
if new_dn != role.ldap_group_dn:
DepartmentRole.objects.filter(pk=role.pk).update(
ldap_group_dn=new_dn
)
role.ldap_group_dn = new_dn
return new_dn
# Создаём новую группу
new_dn = f"{expected_rdn},{dept_dn}"
attrs = {
"sAMAccountName": expected_cn[:20], # SAM ограничен 20 символами
"description": f"Role: {role.name} in {dept.name}",
"groupType": group_type("global", security_enabled=True),
}
ok = conn.add(new_dn, ["top", "group"], attrs)
if not ok:
if "entryAlreadyExists" in str(conn.result):
pass # Уже существует
else:
raise RuntimeError(f"LDAP add role group failed: {conn.result}")
DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn=new_dn)
role.ldap_group_dn = new_dn
return new_dn
def rename_role_group(self, role: DepartmentRole, new_name: str) -> str:
"""Переименовывает группу роли в LDAP.
Args:
role: Роль.
new_name: Новое название роли.
Returns:
str: Новый DN группы.
"""
if not role.ldap_group_dn:
# Группы нет — создаём с новым именем
role.name = new_name
return self._ensure_role_group(role)
new_role_name = self._sanitize_name(new_name)
new_cn = f"ROLE_{new_role_name}"
with _ldap() as conn:
new_dn = self._group_service.rename(conn, role.ldap_group_dn, new_cn=new_cn)
DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn=new_dn)
role.ldap_group_dn = new_dn
return new_dn
def delete_role_group(self, role: DepartmentRole) -> None:
"""Удаляет группу роли из LDAP.
Args:
role: Роль для удаления группы.
"""
if not role.ldap_group_dn:
return
with _ldap() as conn:
try:
self._group_service.delete(conn, role.ldap_group_dn)
except Exception:
pass # Best effort
DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn="")
role.ldap_group_dn = ""
def _sanitize_name(self, name: str) -> str:
"""Очищает имя для использования в CN LDAP-группы.
Args:
name: Исходное имя.
Returns:
str: Очищенное имя.
"""
import re
# Убираем спецсимволы LDAP, заменяем пробелы на _
clean = re.sub(r'[,=+<>#;\\"\']', '', name)
clean = re.sub(r'\s+', '_', clean)
return clean[:50] # Ограничение длины
# ==================== DN/Lookup Methods ==================== #
@ -628,7 +839,10 @@ class DepartmentService:
# ==================== Private OU Methods ==================== #
def _ensure_department_ou(self, conn: Connection, name: str) -> str:
"""Гарантирует наличие OU отдела + OU=Roles.
"""Гарантирует наличие OU отдела.
Группы ролей (ROLE_*) создаются непосредственно в OU отдела,
а не в отдельном OU=Roles (убрано для корректной работы GPO).
Args:
conn: LDAP соединение.
@ -652,7 +866,7 @@ class DepartmentService:
ok = conn.add(dn, ["top", "organizationalUnit"])
if not ok:
raise RuntimeError(f"LDAP add OU failed: {conn.result}")
conn.add(f"OU=Roles,{dn}", ["top", "organizationalUnit"])
# NOTE: OU=Roles больше не создаётся — группы ролей лежат прямо в OU отдела
return dn
def _rename_department_ou(

View File

@ -302,7 +302,8 @@ def export_users_sync_groups(*, cfg: SyncConfig) -> Tuple[int, int]:
dept_for_roles = target_dept or current_dept
if dept_for_roles:
dept_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
extra_bases.append(f"OU=Roles,OU={dept_for_roles},{dept_base}")
# NOTE: Группы ролей теперь лежат прямо в OU отдела (не в OU=Roles)
extra_bases.append(f"OU={dept_for_roles},{dept_base}")
added, removed = sync_user_groups_by_cns(
conn,

View File

@ -117,14 +117,16 @@ def sync_user_groups_by_cns(
)
current_dns = _read_user_memberof_dns(conn, user_dn)
# ограничим удаление только «наших» веток (Groups/OU=Roles),
# ограничим удаление только «наших» веток (Groups, OU отделов с ROLE_*),
# как и в батч-логике
groups_base = getattr(settings, "LDAP_GROUPS_BASE", "")
depts_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
to_add = desired_dns - current_dns
to_del = {
dn
for dn in (current_dns - desired_dns)
if (groups_base and dn.endswith(groups_base)) or "OU=Roles," in dn
if (groups_base and dn.endswith(groups_base))
or (depts_base and dn.endswith(depts_base) and "CN=ROLE_" in dn)
}
added = removed = 0

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.4 on 2025-12-30 06:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0034_alter_employee_telegram'),
]
operations = [
migrations.CreateModel(
name='RoleAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('assigned_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата назначения')),
('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Активно')),
('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_roles', to=settings.AUTH_USER_MODEL, verbose_name='Назначил')),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='employees.departmentrole', verbose_name='Роль')),
],
options={
'verbose_name': 'Назначение роли',
'verbose_name_plural': 'Назначения ролей',
'ordering': ['-assigned_at'],
'indexes': [models.Index(fields=['role', 'is_active'], name='employees_r_role_id_215f2d_idx'), models.Index(fields=['employee', 'is_active'], name='employees_r_employe_5d7a2a_idx')],
'constraints': [models.UniqueConstraint(fields=('employee', 'role'), name='uniq_employee_role_assignment')],
},
),
]

View File

@ -0,0 +1,49 @@
# Generated by Django migration script
# employees/migrations/0036_migrate_role_assignments.py
from django.db import migrations
def migrate_role_data(apps, schema_editor):
"""Переносит назначения ролей из EmployeeDepartment.role в RoleAssignment."""
EmployeeDepartment = apps.get_model('employees', 'EmployeeDepartment')
RoleAssignment = apps.get_model('employees', 'RoleAssignment')
# Находим все связи с назначенными ролями
links_with_roles = EmployeeDepartment.objects.filter(
role__isnull=False
).select_related('employee', 'role')
created_count = 0
for link in links_with_roles:
# Создаём RoleAssignment если ещё нет
assignment, created = RoleAssignment.objects.get_or_create(
employee=link.employee,
role=link.role,
defaults={
'is_active': link.is_active,
'assigned_by': None, # Исторические данные без информации о назначителе
}
)
if created:
created_count += 1
if created_count > 0:
print(f"Migrated {created_count} role assignments from EmployeeDepartment to RoleAssignment")
def reverse_migration(apps, schema_editor):
"""Откат: удаляем все RoleAssignment (данные сохранены в EmployeeDepartment.role)."""
RoleAssignment = apps.get_model('employees', 'RoleAssignment')
RoleAssignment.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('employees', '0035_create_role_assignment'),
]
operations = [
migrations.RunPython(migrate_role_data, reverse_migration),
]

View File

@ -513,6 +513,70 @@ class EmployeeDepartment(DateRangeMixin, models.Model):
return f"{self.employee} в {self.department} ({role_name})"
class RoleAssignment(models.Model):
"""Назначение роли сотруднику (не обязательно члену отдела).
Позволяет назначать роли любым сотрудникам компании,
независимо от их членства в отделе.
Attributes:
employee: Сотрудник, которому назначена роль.
role: Назначенная роль отдела.
assigned_by: Кто назначил роль (опционально).
assigned_at: Дата/время назначения.
is_active: Активно ли назначение.
"""
employee = models.ForeignKey(
"Employee",
on_delete=models.CASCADE,
related_name="role_assignments",
verbose_name="Сотрудник",
)
role = models.ForeignKey(
DepartmentRole,
on_delete=models.CASCADE,
related_name="assignments",
verbose_name="Роль",
)
assigned_by = models.ForeignKey(
"Employee",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="assigned_roles",
verbose_name="Назначил",
)
assigned_at = models.DateTimeField(
"Дата назначения",
auto_now_add=True,
)
is_active = models.BooleanField(
"Активно",
default=True,
db_index=True,
)
class Meta:
verbose_name = "Назначение роли"
verbose_name_plural = "Назначения ролей"
constraints = [
models.UniqueConstraint(
fields=["employee", "role"],
name="uniq_employee_role_assignment",
),
]
indexes = [
models.Index(fields=["role", "is_active"]),
models.Index(fields=["employee", "is_active"]),
]
ordering = ["-assigned_at"]
def __str__(self):
status = "✓" if self.is_active else "✗"
return f"{self.employee} → {self.role} [{status}]"
class Skill(models.Model):
name = models.CharField(max_length=100, unique=True)

View File

@ -332,7 +332,12 @@ export class EmployeesList {
return '';
}
return emp.departments
// Проверяем department_relation для пометки "внешняя роль"
const relation = emp.department_relation;
const hasRoleOnly = relation && relation.has_role_only;
const roleNames = relation && relation.role_names ? relation.role_names.join(', ') : '';
let deptHtml = emp.departments
.filter(d => d.name)
.map(d => {
if (d.id) {
@ -341,6 +346,13 @@ export class EmployeesList {
return this.escapeHtml(d.name);
})
.join(', ');
// Добавляем пометку если сотрудник имеет только роль (не член отдела)
if (hasRoleOnly && roleNames) {
deptHtml += ` <span class="badge bg-info text-white" style="font-size: 0.7rem;" title="Роль назначена без членства в отделе"><i class="bi-link-45deg me-1"></i>${this.escapeHtml(roleNames)}</span>`;
}
return deptHtml;
}
/**

View File

@ -140,32 +140,29 @@
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label for="setRoleSearch-{{ dept.id }}" class="form-label">Участник</label>
<label for="setRoleSearch-{{ dept.id }}" class="form-label">Сотрудник</label>
<input id="setRoleSearch-{{ dept.id }}" type="search"
class="form-control" placeholder="Поиск по ФИО или email" autocomplete="off">
class="form-control" placeholder="Поиск по ФИО или email" autocomplete="off"
data-set-role-q>
<div class="form-text">Можно назначить роль любому сотруднику компании.</div>
<div id="setRoleResults-{{ dept.id }}"
class="list-group list-group-flush border rounded mt-2" style="max-height:320px;overflow:auto;">
{% for li in links %}
{% with emp=li.employee %}
<button type="button"
class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2"
data-id="{{ emp.id }}"
data-name="{{ emp.display_name|default:emp.last_name }}"
data-email="{{ emp.email|default:'' }}">
<span class="card-icon"
style="width:32px;height:32px;flex:0 0 32px;border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;">
{% if emp.avatar %}<img src="{{ emp.avatar }}" alt="" style="width:100%;height:100%;object-fit:cover;">{% else %}<i class="bi-person"></i>{% endif %}
</span>
<span class="d-flex flex-column text-start">
<span class="fw-semibold">{{ emp.display_name|default:emp.last_name }}</span>
{% if emp.email %}<span class="text-muted small">{{ emp.email }}</span>{% endif %}
</span>
</button>
{% endwith %}
{% empty %}
<div class="list-group-item text-secondary small py-3">В отделе пока нет участников.</div>
{% endfor %}
class="list-group list-group-flush border rounded mt-2"
style="max-height:320px;overflow:auto;"
data-set-role-list>
<div class="list-group-item text-muted small py-3">Начните вводить для поиска…</div>
</div>
<template id="setRoleItemTpl-{{ dept.id }}">
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2" data-id="">
<span class="card-icon" style="width:32px;height:32px;flex:0 0 32px;border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;">
<i class="bi-person"></i>
</span>
<span class="d-flex flex-column text-start">
<span class="fw-semibold js-name"></span>
<span class="text-muted small js-email"></span>
</span>
</button>
</template>
</div>
<div class="col-md-6">
<label for="setRoleSelect-{{ dept.id }}" class="form-label">Роль</label>

View File

@ -219,9 +219,11 @@ import { norm } from '{% static "js/utils/stringUtils.js" %}';
})();
</script>
{# ===== Модал «Назначить роль»: подстановка empId в action ===== #}
{# ===== Модал «Назначить роль»: AJAX-поиск по всем сотрудникам ===== #}
<script type="module">
import { norm } from '{% static "js/utils/stringUtils.js" %}';
{% url 'employees:employees_list' as employees_list_url %}
import { esc } from '{% static "js/utils/stringUtils.js" %}';
import { debounce } from '{% static "js/utils/timing.js" %}';
(function(){
const modal = document.getElementById('setRoleModal-{{ dept.id }}');
@ -229,12 +231,15 @@ import { norm } from '{% static "js/utils/stringUtils.js" %}';
const form = modal.querySelector('form');
const actionTpl = form ? form.getAttribute('data-action-template') : null;
const input = document.getElementById('setRoleSearch-{{ dept.id }}');
const listBox = document.getElementById('setRoleResults-{{ dept.id }}');
const input = modal.querySelector('[data-set-role-q]');
const listEl = modal.querySelector('[data-set-role-list]');
const tpl = modal.querySelector('#setRoleItemTpl-{{ dept.id }}');
const hidden = document.getElementById('setRoleChosen-{{ dept.id }}');
const select = document.getElementById('setRoleSelect-{{ dept.id }}');
const submit = document.getElementById('setRoleSubmit-{{ dept.id }}');
if (!form || !input || !listBox || !hidden || !select || !submit) return;
if (!form || !input || !listEl || !tpl || !hidden || !select || !submit) return;
let aborter = null;
function setAction(empId){
if (!actionTpl) return;
@ -243,43 +248,83 @@ import { norm } from '{% static "js/utils/stringUtils.js" %}';
form.setAttribute('action', newAction);
}
function clearActive(){ listBox.querySelectorAll('.active').forEach(el => el.classList.remove('active')); }
function matches(item, q){
const name = norm(item.getAttribute('data-name') || '');
const email = norm(item.getAttribute('data-email') || '');
const id = String(item.getAttribute('data-id') || '');
return !q || name.includes(q) || email.includes(q) || id.includes(q);
async function searchEmployees(q){
if (aborter) aborter.abort();
aborter = new AbortController();
const params = new URLSearchParams({
format: 'json',
q: q,
limit: '20'
});
const r = await fetch('{{ employees_list_url }}?' + params.toString(), {
credentials: 'same-origin',
signal: aborter.signal
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json().catch(()=>({results:[]}));
return Array.isArray(data) ? data : (data.results || []);
}
function applyFilter(){
const q = norm(input.value);
listBox.querySelectorAll('.list-group-item, .list-group-item-action').forEach(it => {
const ok = matches(it, q);
it.classList.toggle('d-none', !ok);
function render(items){
listEl.innerHTML = '';
if (!items.length){
const empty = document.createElement('div');
empty.className = 'list-group-item text-muted small py-3';
empty.textContent = 'Ничего не найдено';
listEl.appendChild(empty);
return;
}
items.forEach(e => {
const node = tpl.content.firstElementChild.cloneNode(true);
node.dataset.id = e.id;
const ava = node.querySelector('.card-icon');
if (ava){
ava.innerHTML = e.avatar
? `<img src="${esc(e.avatar)}" alt="" style="width:100%;height:100%;object-fit:cover;">`
: '<i class="bi-person"></i>';
}
const nameEl = node.querySelector('.js-name');
if (nameEl){
nameEl.textContent = e.display_name || (e.last_name ? (e.last_name + (e.first_name ? ' ' + e.first_name : '')) : ('Сотрудник #' + e.id));
}
const emailEl = node.querySelector('.js-email');
if (emailEl) emailEl.textContent = e.email || '';
node.addEventListener('click', () => {
listEl.querySelectorAll('.list-group-item').forEach(li => li.classList.remove('active'));
node.classList.add('active');
hidden.value = String(e.id);
setAction(e.id);
submit.disabled = false;
submit.removeAttribute('disabled');
});
listEl.appendChild(node);
});
}
input.addEventListener('input', applyFilter);
listBox.addEventListener('click', (e) => {
const it = e.target.closest('[data-id]');
if (!it) return;
clearActive();
it.classList.add('active');
const empId = it.getAttribute('data-id') || '';
hidden.value = empId;
if (empId) {
setAction(empId);
submit.disabled = false;
submit.removeAttribute('disabled');
} else {
submit.disabled = true;
const run = debounce(async (q) => {
submit.disabled = true;
hidden.value = '';
if (!q || q.length < 2) {
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Введите минимум 2 символа для поиска…</div>';
return;
}
});
listEl.innerHTML = '<div class="list-group-item text-muted small py-3"><span class="spinner-border spinner-border-sm me-2"></span>Поиск...</div>';
try { render(await searchEmployees(q)); }
catch (err) {
if (err.name !== 'AbortError') {
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Ошибка загрузки</div>';
}
}
}, 300);
input.addEventListener('input', () => run(input.value.trim()));
modal.addEventListener('shown.bs.modal', () => {
input.value = '';
clearActive();
applyFilter();
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Начните вводить для поиска…</div>';
hidden.value = '';
submit.disabled = true;
input.focus();

View File

@ -138,7 +138,8 @@
<div class="ios-cell dept-item"
data-dept-id="{{ d.id|default:'' }}"
data-role-id="{{ d.role_id|default:'' }}"
data-is-head="{{ d.is_head|yesno:'1,0' }}">
data-is-head="{{ d.is_head|yesno:'1,0' }}"
data-via-assignment="{{ d.via_assignment|yesno:'1,0' }}">
<div class="dept-line">
{% if d.id %}
<a class="text-decoration-none dept-name" href="{% url 'employees:department_detail' d.id %}">{{ d.name }}</a>
@ -148,6 +149,13 @@
<span class="dept-role">
{% if d.role_name %}— {{ d.role_name }}{% endif %}
</span>
{% if d.via_assignment %}
<span class="badge bg-info text-white ms-2" style="font-size: 0.7rem;"
data-bs-toggle="tooltip"
data-bs-title="Роль назначена без членства в отделе">
<i class="bi-link-45deg me-1"></i>внешняя роль
</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">

View File

@ -0,0 +1,222 @@
"""
Тесты для новых endpoints назначения ролей: assign, revoke, assignments.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
from employees.models import (
Department, DepartmentPermission, DepartmentRole,
EmployeeDepartment, RoleAssignment,
)
User = get_user_model()
# В этом проекте User == Employee (кастомная модель)
Employee = User
def _unique_phone() -> str:
"""Генерирует уникальный номер телефона."""
base = 79000000000
return str(base + User.objects.count())
def make_user(email: str, is_staff: bool = False) -> User:
"""Создаёт пользователя (Employee)."""
return User.objects.create_user(
email=email,
password="pwd12345",
phone_number=_unique_phone(),
is_staff=is_staff,
send_activation_email=False,
)
def make_dept(name: str, head: User = None) -> Department:
"""Создаёт отдел."""
return Department.objects.create(name=name, head=head)
def make_role(dept: Department, name: str, perm_codes: list[str]) -> DepartmentRole:
"""Создаёт роль с указанными правами."""
role = DepartmentRole.objects.create(department=dept, name=name)
for code in perm_codes:
perm, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": code}
)
role.scoped_permissions.add(perm)
return role
def grant_assign_perm(user: User, dept: Department) -> DepartmentRole:
"""Даёт пользователю право assign_department_role в отделе."""
role = make_role(dept, "Assigner", ["assign_department_role"])
EmployeeDepartment.objects.create(employee=user, department=dept, role=role)
RoleAssignment.objects.create(employee=user, role=role, is_active=True)
return role
@pytest.fixture
def api_client() -> APIClient:
return APIClient()
@pytest.mark.django_db
class TestRoleAssignmentEndpoints:
"""Тесты для /department-roles/{id}/assign/, revoke, assignments."""
def test_assign_role_to_any_employee(self, api_client: APIClient):
"""Роль можно назначить любому сотруднику, не только члену отдела."""
# Создаём admin, отдел и роль
admin = make_user("admin@test.com", is_staff=True)
dept = make_dept("IT")
role = make_role(dept, "Developer", ["view_request"])
# Создаём сотрудника НЕ в отделе
other_user = make_user("other@test.com")
# Проверяем что сотрудник не член отдела
assert not EmployeeDepartment.objects.filter(
employee=other_user, department=dept
).exists()
# Назначаем роль
api_client.force_authenticate(admin)
url = f"/api/v1/department-roles/{role.id}/assign/"
resp = api_client.post(url, {"employee_id": other_user.id})
assert resp.status_code == status.HTTP_201_CREATED
assert resp.data["employee_id"] == other_user.id
assert resp.data["role_id"] == role.id
assert resp.data["is_active"] is True
# Проверяем что RoleAssignment создан
assert RoleAssignment.objects.filter(
employee=other_user, role=role, is_active=True
).exists()
def test_revoke_role(self, api_client: APIClient):
"""Отзыв роли деактивирует RoleAssignment."""
admin = make_user("admin@test.com", is_staff=True)
dept = make_dept("IT")
role = make_role(dept, "Developer", [])
user = make_user("worker@test.com")
# Назначаем роль
assignment = RoleAssignment.objects.create(
employee=user, role=role, is_active=True
)
# Отзываем
api_client.force_authenticate(admin)
url = f"/api/v1/department-roles/{role.id}/revoke/"
resp = api_client.post(url, {"employee_id": user.id})
assert resp.status_code == status.HTTP_204_NO_CONTENT
# Проверяем что деактивировано
assignment.refresh_from_db()
assert assignment.is_active is False
def test_assignments_list(self, api_client: APIClient):
"""Список назначений роли."""
admin = make_user("admin@test.com", is_staff=True)
dept = make_dept("IT")
role = make_role(dept, "Developer", [])
# Создаём несколько назначений
user1 = make_user("worker1@test.com")
user2 = make_user("worker2@test.com")
RoleAssignment.objects.create(employee=user1, role=role, is_active=True)
RoleAssignment.objects.create(employee=user2, role=role, is_active=False)
api_client.force_authenticate(admin)
# По умолчанию только активные
url = f"/api/v1/department-roles/{role.id}/assignments/"
resp = api_client.get(url)
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
assert resp.data["results"][0]["employee_id"] == user1.id
# Все назначения
resp = api_client.get(url + "?active=all")
assert resp.data["count"] == 2
def test_assign_requires_permission(self, api_client: APIClient):
"""Назначение требует право assign_department_role."""
dept = make_dept("IT")
role = make_role(dept, "Developer", [])
# Пользователь без прав
user = make_user("nopriv@test.com")
other_user = make_user("other@test.com")
api_client.force_authenticate(user)
url = f"/api/v1/department-roles/{role.id}/assign/"
resp = api_client.post(url, {"employee_id": other_user.id})
assert resp.status_code == status.HTTP_403_FORBIDDEN
def test_assign_with_dept_permission(self, api_client: APIClient):
"""Пользователь с assign_department_role может назначать роли."""
dept = make_dept("IT")
role = make_role(dept, "Developer", [])
# Пользователь с правом
user = make_user("assigner@test.com")
grant_assign_perm(user, dept)
other_user = make_user("other2@test.com")
api_client.force_authenticate(user)
url = f"/api/v1/department-roles/{role.id}/assign/"
resp = api_client.post(url, {"employee_id": other_user.id})
assert resp.status_code == status.HTTP_201_CREATED
@pytest.mark.django_db
class TestRoleAssignmentPermissions:
"""Тесты для проверки прав через RoleAssignment."""
def test_permission_check_via_role_assignment(self, api_client: APIClient):
"""Права проверяются через RoleAssignment, не требуя членства в отделе."""
from api.v1.permissions import has_dept_perm
dept = make_dept("IT")
role = make_role(dept, "Manager", ["manage_department", "view_request"])
user = make_user("manager@test.com")
# Пользователь НЕ член отдела
assert not EmployeeDepartment.objects.filter(
employee=user, department=dept
).exists()
# Но у него есть RoleAssignment
RoleAssignment.objects.create(employee=user, role=role, is_active=True)
# Проверяем права
assert has_dept_perm(user, dept.id, "manage_department")
assert has_dept_perm(user, dept.id, "view_request")
assert not has_dept_perm(user, dept.id, "change_department_head")
def test_inactive_assignment_does_not_grant_permission(self, api_client: APIClient):
"""Неактивное назначение не даёт прав."""
from api.v1.permissions import has_dept_perm
dept = make_dept("IT")
role = make_role(dept, "Manager", ["manage_department"])
user = make_user("manager2@test.com")
# Неактивное назначение
RoleAssignment.objects.create(employee=user, role=role, is_active=False)
assert not has_dept_perm(user, dept.id, "manage_department")