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:
parent
5e0e058b3a
commit
fff16130f3
@ -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:
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
429
backend/docs/reports/01_DEPARTMENT_ROLES_MODELS.md
Normal file
429
backend/docs/reports/01_DEPARTMENT_ROLES_MODELS.md
Normal 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
|
||||
572
backend/docs/reports/02_DEPARTMENT_ROLES_API.md
Normal file
572
backend/docs/reports/02_DEPARTMENT_ROLES_API.md
Normal 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) — Сериализаторы и валидация
|
||||
710
backend/docs/reports/03_DEPARTMENT_ROLES_SERIALIZERS.md
Normal file
710
backend/docs/reports/03_DEPARTMENT_ROLES_SERIALIZERS.md
Normal 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) — Система проверки прав
|
||||
763
backend/docs/reports/04_DEPARTMENT_ROLES_PERMISSIONS.md
Normal file
763
backend/docs/reports/04_DEPARTMENT_ROLES_PERMISSIONS.md
Normal 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) — Константы и утилиты
|
||||
647
backend/docs/reports/05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md
Normal file
647
backend/docs/reports/05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md
Normal 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 интеграция
|
||||
625
backend/docs/reports/06_DEPARTMENT_ROLES_LDAP.md
Normal file
625
backend/docs/reports/06_DEPARTMENT_ROLES_LDAP.md
Normal 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) — Покрытие тестами
|
||||
659
backend/docs/reports/07_DEPARTMENT_ROLES_TESTS.md
Normal file
659
backend/docs/reports/07_DEPARTMENT_ROLES_TESTS.md
Normal 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) — Архитектура и рекомендации
|
||||
824
backend/docs/reports/08_DEPARTMENT_ROLES_ARCHITECTURE.md
Normal file
824
backend/docs/reports/08_DEPARTMENT_ROLES_ARCHITECTURE.md
Normal 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) ← **Вы здесь**
|
||||
817
backend/docs/reports/09_ROLES_LDAP_REDESIGN.md
Normal file
817
backend/docs/reports/09_ROLES_LDAP_REDESIGN.md
Normal 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)
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
33
backend/employees/migrations/0035_create_role_assignment.py
Normal file
33
backend/employees/migrations/0035_create_role_assignment.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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">
|
||||
|
||||
222
backend/tests/api/v1/employees/test_role_assignment.py
Normal file
222
backend/tests/api/v1/employees/test_role_assignment.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user