1705 lines
61 KiB
Python
1705 lines
61 KiB
Python
"""Сервис для управления отделами в LDAP и Django.
|
||
|
||
Этот модуль содержит бизнес-логику для CRUD операций с отделами (Department),
|
||
включая синхронизацию между Active Directory OU и Django ORM.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from django.conf import settings
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
|
||
from ...models import (
|
||
Department,
|
||
DepartmentRole,
|
||
Employee,
|
||
EmployeeDepartment,
|
||
LdapSyncState,
|
||
RoleAssignment,
|
||
)
|
||
from ..domain.dtos import DirectoryDepartmentDTO
|
||
from ..errors import (
|
||
DirectoryDbError,
|
||
DirectoryLdapError,
|
||
DirectoryServiceError,
|
||
)
|
||
from ..orm_models import (
|
||
LdapOrganizationalUnit,
|
||
LdapOrganizationalUnitGroup,
|
||
LdapUser,
|
||
)
|
||
from ..utils.ldap_utils import get_base_dn_for_employee
|
||
from ..utils.text_utils import esc_rdn
|
||
from .base_service import BaseService
|
||
from .constants import SyncDirection
|
||
|
||
|
||
class DepartmentService(BaseService):
|
||
"""Сервис для управления отделами в LDAP и Django.
|
||
|
||
Рефакторенная версия с улучшениями:
|
||
- Наследуется от BaseService (логирование, _touch_state)
|
||
- Использует константы вместо магических строк
|
||
- Логирует все критические операции
|
||
"""
|
||
|
||
def __init__(self, group_service=None, user_service=None):
|
||
"""Инициализация сервиса.
|
||
|
||
Args:
|
||
group_service: Сервис для работы с группами (Dependency Injection)
|
||
user_service: Сервис для работы с пользователями
|
||
(Dependency Injection)
|
||
"""
|
||
super().__init__()
|
||
self._group_service = group_service
|
||
self._user_service = user_service
|
||
|
||
def _ensure_runtime_services(self) -> None:
|
||
"""Ленивая инициализация зависимостей для sync/retry путей."""
|
||
if self._group_service is None:
|
||
from .group_service import GroupService
|
||
|
||
self._group_service = GroupService()
|
||
|
||
if self._user_service is None:
|
||
from .user_service import UserService
|
||
|
||
self._user_service = UserService(self._group_service)
|
||
|
||
def _load_ldap_user(
|
||
self,
|
||
employee: Employee,
|
||
*,
|
||
expected_dn: Optional[str] = None,
|
||
) -> LdapUser:
|
||
"""Возвращает high-level LDAP модель пользователя.
|
||
|
||
Ищет сначала по DN из sync state, затем по employeeNumber.
|
||
Если объект найден не по ожидаемому DN, корректирует sync state.
|
||
"""
|
||
candidate_dn = (expected_dn or "").strip()
|
||
ldap_user: Optional[LdapUser] = None
|
||
|
||
if candidate_dn:
|
||
try:
|
||
ldap_user = LdapUser.objects.get(dn=candidate_dn)
|
||
except LdapUser.DoesNotExist:
|
||
ldap_user = None
|
||
|
||
if ldap_user is None:
|
||
ldap_user = LdapUser.objects.filter(
|
||
employee_number=str(employee.pk)
|
||
).first()
|
||
|
||
if ldap_user is None:
|
||
raise DirectoryServiceError(
|
||
f"LDAP user not found for employee {employee.pk}"
|
||
)
|
||
|
||
actual_dn = str(ldap_user.dn)
|
||
if actual_dn != candidate_dn:
|
||
self._touch_state(
|
||
model="employee",
|
||
object_pk=employee.pk,
|
||
ldap_dn=actual_dn,
|
||
sync_dir=SyncDirection.AUTO,
|
||
)
|
||
|
||
return ldap_user
|
||
|
||
def _move_employee_to_base_dn(
|
||
self,
|
||
employee: Employee,
|
||
target_base_dn: str,
|
||
*,
|
||
current_dn: Optional[str] = None,
|
||
) -> str:
|
||
"""Перемещает сотрудника через ORM-модель LdapUser."""
|
||
ldap_user = self._load_ldap_user(employee, expected_dn=current_dn)
|
||
_, current_parent = self._split_rdn_parent(str(ldap_user.dn))
|
||
if current_parent.lower() == target_base_dn.lower():
|
||
return str(ldap_user.dn)
|
||
|
||
ldap_user.move_to(target_base_dn)
|
||
new_dn = str(ldap_user.dn)
|
||
self._touch_state(
|
||
model="employee",
|
||
object_pk=employee.pk,
|
||
ldap_dn=new_dn,
|
||
sync_dir=SyncDirection.AUTO,
|
||
)
|
||
return new_dn
|
||
|
||
def _load_ou_group(self, group_dn: str) -> LdapOrganizationalUnitGroup:
|
||
"""Возвращает high-level LDAP модель группы внутри OU отдела."""
|
||
try:
|
||
return LdapOrganizationalUnitGroup.objects.get(dn=group_dn)
|
||
except LdapOrganizationalUnitGroup.DoesNotExist as e:
|
||
raise DirectoryServiceError(f"Group not found: {group_dn}") from e
|
||
|
||
def _add_ou_group_member(self, group_dn: str, member_dn: str) -> None:
|
||
"""Добавляет DN в группу отдела/роли через ORM модель."""
|
||
group = self._load_ou_group(group_dn)
|
||
group.add_member(member_dn)
|
||
|
||
def _remove_ou_group_member(self, group_dn: str, member_dn: str) -> None:
|
||
"""Удаляет DN из группы отдела/роли через ORM модель."""
|
||
group = self._load_ou_group(group_dn)
|
||
group.remove_member(member_dn)
|
||
|
||
# ==================== Public API ==================== #
|
||
|
||
def sync_department_state(
|
||
self,
|
||
dept: Department,
|
||
*,
|
||
created: bool,
|
||
changes: Optional[Dict[str, Any]] = None,
|
||
sync_head: bool = False,
|
||
) -> str:
|
||
"""Приводит LDAP состояние отдела к текущему состоянию в Django.
|
||
|
||
Метод предназначен для сигналов и retry-очереди: Django уже содержит
|
||
истинное состояние, а здесь выполняется только LDAP reconcile.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
normalized_changes = {
|
||
key: value
|
||
for key, value in (changes or {}).items()
|
||
if key in {"name", "description"}
|
||
}
|
||
|
||
sync_state = LdapSyncState.objects.filter(
|
||
model="department", object_pk=str(dept.pk)
|
||
).first()
|
||
needs_bootstrap = created or not sync_state or not sync_state.ldap_dn
|
||
|
||
if needs_bootstrap:
|
||
dept_dn = self._ensure_department_ou(dept.name)
|
||
self._touch_state(
|
||
model="department",
|
||
object_pk=dept.pk,
|
||
ldap_dn=dept_dn,
|
||
last_django_modify_ts=timezone.now(),
|
||
sync_dir=SyncDirection.AUTO,
|
||
)
|
||
self._set_ou_description(dept_dn, dept.description or "")
|
||
head_dn = None
|
||
if dept.head_id:
|
||
try:
|
||
head_dn = self._user_service._get_employee_dn(dept.head)
|
||
except Exception:
|
||
head_dn = None
|
||
self._set_ou_managed_by(dept_dn, head_dn)
|
||
self._reconcile_department_group(dept, dept_dn)
|
||
return dept_dn
|
||
|
||
update_changes: Dict[str, Any] = {}
|
||
if "name" in normalized_changes:
|
||
update_changes["name"] = dept.name
|
||
if "description" in normalized_changes:
|
||
update_changes["description"] = dept.description
|
||
if sync_head:
|
||
update_changes["head"] = dept.head
|
||
|
||
if update_changes:
|
||
self.update_department(dept, update_changes)
|
||
|
||
return self._get_department_dn(dept)
|
||
|
||
def sync_department_delete(
|
||
self,
|
||
*,
|
||
object_pk: str | int,
|
||
dept_dn: Optional[str] = None,
|
||
) -> None:
|
||
"""Удаляет LDAP следы отдела после удаления Django-записи."""
|
||
self._ensure_runtime_services()
|
||
|
||
object_pk = str(object_pk)
|
||
resolved_dept_dn = dept_dn or (
|
||
LdapSyncState.objects.filter(model="department", object_pk=object_pk)
|
||
.values_list("ldap_dn", flat=True)
|
||
.first()
|
||
)
|
||
if not resolved_dept_dn:
|
||
return
|
||
|
||
self._evict_all_users_from_department_ou(resolved_dept_dn)
|
||
|
||
for role in DepartmentRole.objects.filter(department_id=object_pk).exclude(
|
||
ldap_group_dn=""
|
||
):
|
||
self.delete_role_group(role)
|
||
|
||
dept = Department.objects.filter(pk=object_pk).first()
|
||
if dept and dept.ldap_group_dn:
|
||
try:
|
||
self._load_ou_group(dept.ldap_group_dn).delete()
|
||
except Exception as error:
|
||
self._logger.warning(
|
||
"LDAP department group cleanup failed",
|
||
extra={
|
||
"event": "ldap.department.group_cleanup_failed",
|
||
"department_id": object_pk,
|
||
"error_type": type(error).__name__,
|
||
},
|
||
exc_info=True,
|
||
)
|
||
|
||
self._delete_department_ou(resolved_dept_dn)
|
||
|
||
LdapSyncState.objects.filter(model="department", object_pk=object_pk).delete()
|
||
|
||
def sync_member_state(
|
||
self,
|
||
employee: Employee,
|
||
department: Department,
|
||
*,
|
||
is_active: bool,
|
||
role: Any = None,
|
||
) -> None:
|
||
"""Приводит LDAP-состояние членства сотрудника в отделе к DB state."""
|
||
self._ensure_runtime_services()
|
||
|
||
try:
|
||
emp_dn = self._user_service._get_employee_dn(employee)
|
||
except Exception as error:
|
||
self._logger.debug(
|
||
"LDAP department member sync skipped because employee DN is missing",
|
||
extra={
|
||
"event": "ldap.department.member_sync_skipped",
|
||
"employee_id": employee.pk,
|
||
"department_id": department.pk,
|
||
"reason": "employee_dn_missing",
|
||
"error_type": type(error).__name__,
|
||
},
|
||
)
|
||
return
|
||
|
||
if is_active:
|
||
dept_dn = self.sync_department_state(
|
||
department,
|
||
created=False,
|
||
changes=None,
|
||
sync_head=False,
|
||
)
|
||
emp_dn = self._move_employee_to_base_dn(
|
||
employee,
|
||
dept_dn,
|
||
current_dn=emp_dn,
|
||
)
|
||
group_dn = self._ensure_department_group(department, dept_dn)
|
||
if group_dn:
|
||
self._add_ou_group_member(group_dn, emp_dn)
|
||
else:
|
||
grp_dn = (department.ldap_group_dn or "").strip()
|
||
if grp_dn:
|
||
try:
|
||
self._remove_ou_group_member(grp_dn, emp_dn)
|
||
except Exception as error:
|
||
self._logger.warning(
|
||
"LDAP department membership removal failed",
|
||
extra={
|
||
"event": "ldap.department.membership_remove_failed",
|
||
"employee_id": employee.pk,
|
||
"department_id": department.pk,
|
||
"error_type": type(error).__name__,
|
||
},
|
||
exc_info=True,
|
||
)
|
||
|
||
target_base = get_base_dn_for_employee(employee)
|
||
emp_dn = self._move_employee_to_base_dn(
|
||
employee,
|
||
target_base,
|
||
current_dn=emp_dn,
|
||
)
|
||
|
||
self._reconcile_member_role_memberships(
|
||
employee,
|
||
department,
|
||
is_active=is_active,
|
||
role=role,
|
||
)
|
||
|
||
def create_department(self, dto: DirectoryDepartmentDTO) -> Department:
|
||
"""Создаёт OU отдела в LDAP и согласованную запись Department в БД.
|
||
|
||
Args:
|
||
dto: Данные отдела.
|
||
|
||
Returns:
|
||
Department: Созданный отдел.
|
||
|
||
Raises:
|
||
DirectoryLdapError: Не удалось создать/настроить OU в LDAP.
|
||
DirectoryDbError: Не удалось создать запись в БД
|
||
(OU будет удалён).
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
dept_dn: Optional[str] = None
|
||
group_dn: Optional[str] = None
|
||
try:
|
||
dept_dn = self._ensure_department_ou(dto.name)
|
||
self._set_ou_description(dept_dn, dto.description or "")
|
||
head_dn = None
|
||
if dto.head:
|
||
try:
|
||
head_dn = self._user_service._get_employee_dn(dto.head)
|
||
except Exception:
|
||
head_dn = None
|
||
self._set_ou_managed_by(dept_dn, head_dn)
|
||
group_dn = self._ensure_department_group(
|
||
Department(name=dto.name, ldap_group_dn=""),
|
||
dept_dn,
|
||
)
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP create OU failed: {e}") from e
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
dept = Department.objects.create(
|
||
name=dto.name,
|
||
description=dto.description or "",
|
||
head=dto.head or None,
|
||
ldap_group_dn=group_dn or "",
|
||
)
|
||
self._reconcile_department_group(dept, dept_dn)
|
||
self._touch_state(
|
||
model="department",
|
||
object_pk=dept.pk,
|
||
ldap_dn=dept_dn,
|
||
last_django_modify_ts=timezone.now(),
|
||
sync_dir=SyncDirection.DJANGO,
|
||
)
|
||
self._log_operation(
|
||
"create",
|
||
model="department",
|
||
object_id=dept.pk,
|
||
dn=dept_dn,
|
||
success=True,
|
||
)
|
||
except Exception as e:
|
||
try:
|
||
if group_dn:
|
||
self._load_ou_group(group_dn).delete()
|
||
if dept_dn:
|
||
self._delete_department_ou(dept_dn)
|
||
finally:
|
||
pass
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
return dept
|
||
|
||
def update_department(
|
||
self, dept: Department, changes: Dict[str, Any]
|
||
) -> Department:
|
||
"""Обновляет OU отдела, одноименную группу и запись Department.
|
||
|
||
Args:
|
||
dept: Модель отдела для обновления.
|
||
changes: Словарь изменений (name, description, head).
|
||
|
||
Returns:
|
||
Department: Обновлённая модель отдела.
|
||
|
||
Raises:
|
||
DirectoryServiceError: Если не найден DN отдела.
|
||
DirectoryLdapError: Ошибка при обновлении в LDAP.
|
||
DirectoryDbError: Ошибка при сохранении в БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
try:
|
||
current_dn = self._get_department_dn(dept)
|
||
except DirectoryServiceError as e:
|
||
raise DirectoryServiceError(f"Не найден DN отдела (sync state): {e}") from e
|
||
|
||
new_dn = current_dn
|
||
target_name = str(changes.get("name") or dept.name or "").strip()
|
||
target_description = changes.get("description", dept.description)
|
||
target_head = changes.get("head", dept.head)
|
||
|
||
try:
|
||
ou = self._get_department_ou(current_dn)
|
||
desired_rdn = f"OU={target_name}" if target_name else ""
|
||
current_rdn = current_dn.split(",", 1)[0]
|
||
name_changed = bool(
|
||
"name" in changes and target_name and current_rdn != desired_rdn
|
||
)
|
||
|
||
if name_changed:
|
||
ou.ou = target_name
|
||
if "description" in changes:
|
||
ou.description = target_description or ""
|
||
if "head" in changes:
|
||
head_dn = None
|
||
if target_head:
|
||
try:
|
||
head_dn = self._user_service._get_employee_dn(target_head)
|
||
except Exception:
|
||
head_dn = None
|
||
ou.managed_by = head_dn or ""
|
||
if name_changed or "description" in changes or "head" in changes:
|
||
ou.save()
|
||
new_dn = str(ou.dn)
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP update OU failed: {e}") from e
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
upd_fields: List[str] = []
|
||
for k, v in changes.items():
|
||
if hasattr(dept, k) and getattr(dept, k) != v:
|
||
setattr(dept, k, v)
|
||
upd_fields.append(k)
|
||
if upd_fields:
|
||
dept.save(update_fields=list(set(upd_fields)))
|
||
|
||
self._touch_state(
|
||
model="department",
|
||
object_pk=dept.pk,
|
||
**(
|
||
{
|
||
"ldap_dn": new_dn,
|
||
"last_django_modify_ts": timezone.now(),
|
||
"sync_dir": "django",
|
||
}
|
||
if new_dn != current_dn
|
||
else {
|
||
"last_django_modify_ts": timezone.now(),
|
||
"sync_dir": "django",
|
||
}
|
||
),
|
||
)
|
||
except Exception as e:
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
if new_dn != current_dn:
|
||
try:
|
||
self._sync_descendant_dns_after_ou_rename(
|
||
old_dn=current_dn, new_dn=new_dn
|
||
)
|
||
except Exception as e:
|
||
raise DirectoryDbError(f"Cascade DN rewrite failed: {e}") from e
|
||
self._reconcile_department_group(dept, new_dn)
|
||
return dept
|
||
|
||
def delete_department(self, dept: Department) -> None:
|
||
"""Удаляет отдел: исключает сотрудников → удаляет группу → OU → БД.
|
||
|
||
Args:
|
||
dept: Отдел для удаления.
|
||
|
||
Raises:
|
||
DirectoryLdapError: Ошибка при удалении в LDAP.
|
||
DirectoryDbError: Ошибка при удалении из БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
try:
|
||
dept_dn = self._get_department_dn(dept)
|
||
except DirectoryServiceError:
|
||
dept_dn = None
|
||
|
||
try:
|
||
if dept.ldap_group_dn:
|
||
self._load_ou_group(dept.ldap_group_dn).delete()
|
||
except Exception as error:
|
||
self._logger.warning(
|
||
"LDAP department group deletion failed",
|
||
extra={
|
||
"event": "ldap.department.group_delete_failed",
|
||
"department_id": dept.pk,
|
||
"error_type": type(error).__name__,
|
||
},
|
||
exc_info=True,
|
||
)
|
||
|
||
if dept_dn:
|
||
try:
|
||
self._evict_all_users_from_department_ou(dept_dn)
|
||
self._delete_department_ou(dept_dn)
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP delete OU failed: {e}") from e
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
pk = dept.pk
|
||
dept_name = dept.name
|
||
dept.delete()
|
||
LdapSyncState.objects.filter(
|
||
model="department", object_pk=str(pk)
|
||
).delete()
|
||
self._log_operation(
|
||
"delete",
|
||
model="department",
|
||
object_id=pk,
|
||
dn=dept_dn,
|
||
success=True,
|
||
extra={"name": dept_name},
|
||
)
|
||
except Exception as e:
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
def add_member(self, dept: Department, employee: Employee) -> None:
|
||
"""Добавляет сотрудника в OU отдела и в одноименную группу отдела.
|
||
|
||
Args:
|
||
dept: Отдел.
|
||
employee: Сотрудник для добавления.
|
||
|
||
Raises:
|
||
DirectoryServiceError: Если сотрудник уже в другом отделе.
|
||
DirectoryLdapError: Ошибка при добавлении в LDAP.
|
||
DirectoryDbError: Ошибка при создании связи в БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
existing_link = EmployeeDepartment.objects.filter(
|
||
employee_id=employee.id, is_active=True
|
||
).first()
|
||
if existing_link:
|
||
raise DirectoryServiceError(
|
||
f"Сотрудник уже состоит в отделе: {existing_link.department.name}"
|
||
)
|
||
|
||
emp_dn = self._user_service._get_employee_dn(employee)
|
||
try:
|
||
dept_dn = None
|
||
try:
|
||
dept_dn = self._get_department_dn(dept)
|
||
except DirectoryServiceError:
|
||
dept_dn = None
|
||
ensured_dn = self._ensure_department_ou(dept.name)
|
||
if not dept_dn or dept_dn != ensured_dn:
|
||
self._touch_state(
|
||
model="department",
|
||
object_pk=dept.pk,
|
||
ldap_dn=ensured_dn,
|
||
last_django_modify_ts=timezone.now(),
|
||
sync_dir=SyncDirection.AUTO,
|
||
)
|
||
dept_dn = ensured_dn
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP ensure OU failed: {e}") from e
|
||
|
||
try:
|
||
emp_dn = self._move_employee_to_base_dn(
|
||
employee,
|
||
dept_dn,
|
||
current_dn=emp_dn,
|
||
)
|
||
group_dn = self._ensure_department_group(dept, dept_dn)
|
||
if not group_dn:
|
||
raise RuntimeError("_ensure_department_group returned empty DN")
|
||
self._add_ou_group_member(group_dn, emp_dn)
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP add to department group failed: {e}") from e
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
link, _ = EmployeeDepartment.objects.get_or_create(
|
||
employee_id=employee.id,
|
||
department_id=dept.id,
|
||
defaults={"is_active": True},
|
||
)
|
||
link.is_active = True
|
||
link.save(update_fields=["is_active"])
|
||
except Exception as e:
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
def remove_member(self, dept: Department, employee: Employee) -> None:
|
||
"""Удаляет сотрудника из отдела:
|
||
из группы → MOVE в Users OU → удаление линка.
|
||
|
||
Args:
|
||
dept: Отдел.
|
||
employee: Сотрудник для удаления.
|
||
|
||
Raises:
|
||
DirectoryServiceError: Если пытаемся удалить руководителя.
|
||
DirectoryLdapError: Ошибка при удалении из LDAP.
|
||
DirectoryDbError: Ошибка при удалении связи в БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
if dept.head_id == employee.id:
|
||
raise DirectoryServiceError("Нельзя удалить руководителя отдела")
|
||
|
||
try:
|
||
emp_dn = self._user_service._get_employee_dn(employee)
|
||
except DirectoryServiceError:
|
||
emp_dn = None
|
||
|
||
try:
|
||
grp_dn = (dept.ldap_group_dn or "").strip()
|
||
if grp_dn and emp_dn:
|
||
self._remove_ou_group_member(grp_dn, emp_dn)
|
||
except Exception as error:
|
||
self._logger.warning(
|
||
"LDAP member group cleanup failed",
|
||
extra={
|
||
"event": "ldap.department.member_group_cleanup_failed",
|
||
"employee_id": employee.pk,
|
||
"department_id": dept.pk,
|
||
"error_type": type(error).__name__,
|
||
},
|
||
exc_info=True,
|
||
)
|
||
|
||
if emp_dn:
|
||
try:
|
||
target_base = get_base_dn_for_employee(employee)
|
||
except RuntimeError as e:
|
||
raise DirectoryLdapError(str(e)) from e
|
||
|
||
try:
|
||
self._move_employee_to_base_dn(
|
||
employee,
|
||
target_base,
|
||
current_dn=emp_dn,
|
||
)
|
||
except Exception as e:
|
||
target_name = "Dismissed OU" if not employee.is_active else "Users OU"
|
||
raise DirectoryLdapError(
|
||
f"LDAP move to {target_name} failed: {e}"
|
||
) from e
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
EmployeeDepartment.objects.filter(
|
||
employee_id=employee.id, department_id=dept.id
|
||
).delete()
|
||
except Exception as e:
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
def set_head(self, dept: Department, head: Optional[Employee]) -> Department:
|
||
"""Назначает/снимает руководителя отдела: LDAP managedBy → DB.head.
|
||
|
||
Args:
|
||
dept: Модель отдела.
|
||
head: Новый руководитель или None (снятие).
|
||
|
||
Returns:
|
||
Department: Обновлённая модель отдела.
|
||
|
||
Raises:
|
||
DirectoryServiceError: Если не удаётся определить DN объекта.
|
||
DirectoryLdapError: Ошибка при записи в LDAP.
|
||
DirectoryDbError: Ошибка при сохранении в БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
try:
|
||
dept_dn = None
|
||
try:
|
||
dept_dn = self._get_department_dn(dept)
|
||
except DirectoryServiceError:
|
||
dept_dn = None
|
||
ensured_dn = self._ensure_department_ou(dept.name)
|
||
if not dept_dn or dept_dn != ensured_dn:
|
||
self._touch_state(
|
||
model="department",
|
||
object_pk=dept.pk,
|
||
ldap_dn=ensured_dn,
|
||
last_django_modify_ts=timezone.now(),
|
||
sync_dir=SyncDirection.AUTO,
|
||
)
|
||
dept_dn = ensured_dn
|
||
except Exception as e:
|
||
raise DirectoryLdapError(
|
||
f"LDAP ensure/get department OU failed: {e}"
|
||
) from e
|
||
|
||
head_dn: Optional[str] = None
|
||
if head is not None:
|
||
head_dn = self._user_service._get_employee_dn(head)
|
||
if not head_dn:
|
||
raise DirectoryServiceError("Head has no ldap_dn")
|
||
|
||
try:
|
||
self._set_ou_managed_by(dept_dn, head_dn)
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP set managedBy failed: {e}") from e
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
dept.head = head
|
||
dept.save(update_fields=["head"])
|
||
except Exception as e:
|
||
raise DirectoryDbError(str(e)) from e
|
||
self._reconcile_department_group(dept, dept_dn)
|
||
|
||
return dept
|
||
|
||
# ==================== Role CRUD (LDAP → DB) ==================== #
|
||
|
||
def create_role(
|
||
self,
|
||
department: Department,
|
||
name: str,
|
||
description: str = "",
|
||
scoped_permissions: Optional[list] = None,
|
||
) -> DepartmentRole:
|
||
"""Создаёт роль: сначала группу в LDAP, затем запись в БД.
|
||
|
||
Аналогично create_department — сначала LDAP, потом БД.
|
||
При ошибке БД откатываем созданную группу в LDAP.
|
||
|
||
Args:
|
||
department: Отдел для роли.
|
||
name: Название роли.
|
||
description: Описание роли.
|
||
scoped_permissions: Список DepartmentPermission для назначения.
|
||
|
||
Returns:
|
||
DepartmentRole: Созданная роль.
|
||
|
||
Raises:
|
||
DirectoryLdapError: Ошибка создания группы в LDAP.
|
||
DirectoryDbError: Ошибка создания записи в БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
# 1. Создаём группу в LDAP
|
||
dept_dn = self._get_department_dn(department)
|
||
role_name = self._sanitize_name(name)
|
||
cn = f"ROLE_{role_name}"
|
||
group_dn = f"CN={esc_rdn(cn)},{dept_dn}"
|
||
sam_account_name = self._build_role_sam_account_name(department, name)
|
||
|
||
try:
|
||
if not LdapOrganizationalUnitGroup.objects.filter(dn=group_dn).exists():
|
||
LdapOrganizationalUnitGroup.objects.create(
|
||
dn=group_dn,
|
||
cn=cn,
|
||
sam_account_name=sam_account_name,
|
||
description=description or f"Role: {name} in {department.name}",
|
||
)
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP create role group failed: {e}") from e
|
||
|
||
# 2. Создаём запись в БД
|
||
try:
|
||
with transaction.atomic():
|
||
role = DepartmentRole.objects.create(
|
||
department=department,
|
||
name=name,
|
||
ldap_group_dn=group_dn,
|
||
)
|
||
if scoped_permissions:
|
||
role.scoped_permissions.set(scoped_permissions)
|
||
except Exception as e:
|
||
try:
|
||
self._load_ou_group(group_dn).delete()
|
||
except Exception as cleanup_error:
|
||
self._logger.warning(
|
||
"LDAP role group rollback failed",
|
||
extra={
|
||
"event": "ldap.role.group_rollback_failed",
|
||
"department_id": department.pk,
|
||
"error_type": type(cleanup_error).__name__,
|
||
},
|
||
exc_info=True,
|
||
)
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
return role
|
||
|
||
def update_role(
|
||
self,
|
||
role: DepartmentRole,
|
||
changes: Dict[str, Any],
|
||
) -> DepartmentRole:
|
||
"""Обновляет роль: сначала в LDAP, затем в БД.
|
||
|
||
Args:
|
||
role: Роль для обновления.
|
||
changes: Словарь изменений
|
||
(name, scoped_permissions, scoped_permission_codes).
|
||
|
||
Returns:
|
||
DepartmentRole: Обновлённая роль.
|
||
|
||
Raises:
|
||
DirectoryLdapError: Ошибка обновления в LDAP.
|
||
DirectoryDbError: Ошибка сохранения в БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
new_name = changes.get("name")
|
||
|
||
# Если меняется имя — переименовываем группу в LDAP
|
||
if new_name and new_name != role.name:
|
||
if role.ldap_group_dn:
|
||
try:
|
||
new_role_name = self._sanitize_name(new_name)
|
||
new_cn = f"ROLE_{new_role_name}"
|
||
new_sam = self._build_role_sam_account_name(
|
||
role.department, new_name
|
||
)
|
||
role_group = LdapOrganizationalUnitGroup.objects.get(
|
||
dn=role.ldap_group_dn
|
||
)
|
||
role_group.cn = new_cn
|
||
role_group.sam_account_name = new_sam
|
||
role_group.save()
|
||
new_dn = str(role_group.dn)
|
||
role.ldap_group_dn = new_dn
|
||
except Exception as e:
|
||
raise DirectoryLdapError(
|
||
f"LDAP rename role group failed: {e}"
|
||
) from e
|
||
else:
|
||
# Группы нет — создаём
|
||
role.name = new_name
|
||
self._ensure_role_group(role)
|
||
|
||
# Обновляем БД
|
||
try:
|
||
with transaction.atomic():
|
||
if new_name:
|
||
role.name = new_name
|
||
role.save()
|
||
|
||
# Обновляем права
|
||
perms = changes.get("scoped_permissions")
|
||
codes = changes.get("scoped_permission_codes")
|
||
|
||
if perms is not None:
|
||
role.scoped_permissions.set(perms)
|
||
elif codes is not None:
|
||
from employees.models import DepartmentPermission
|
||
|
||
qs = DepartmentPermission.objects.filter(code__in=codes)
|
||
role.scoped_permissions.set(list(qs))
|
||
except Exception as e:
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
return role
|
||
|
||
def delete_role(self, role: DepartmentRole) -> None:
|
||
"""
|
||
Args:
|
||
role: Роль для удаления.
|
||
|
||
Raises:
|
||
DirectoryLdapError: Ошибка удаления группы из LDAP.
|
||
DirectoryDbError: Ошибка удаления записи из БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
# 1. Удаляем группу из LDAP
|
||
if role.ldap_group_dn:
|
||
try:
|
||
self._load_ou_group(role.ldap_group_dn).delete()
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP delete role group failed: {e}") from e
|
||
|
||
# 2. Удаляем запись из БД
|
||
try:
|
||
role.delete()
|
||
except Exception as e:
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
def sync_role_state(self, role: DepartmentRole) -> None:
|
||
"""Приводит LDAP-состояние роли к текущему состоянию БД."""
|
||
self._ensure_runtime_services()
|
||
self._ensure_role_group(role)
|
||
|
||
try:
|
||
dept_dn = self._get_department_dn(role.department)
|
||
except DirectoryServiceError:
|
||
dept_dn = None
|
||
self._reconcile_department_group(role.department, dept_dn)
|
||
|
||
def sync_role_delete(
|
||
self, department: Department, role_group_dn: str | None = None
|
||
) -> None:
|
||
"""Удаляет LDAP-группу роли и пересобирает группу отдела."""
|
||
self._ensure_runtime_services()
|
||
|
||
if role_group_dn:
|
||
try:
|
||
self._load_ou_group(role_group_dn).delete()
|
||
except Exception as error:
|
||
self._logger.warning(
|
||
"LDAP role group deletion failed",
|
||
extra={
|
||
"event": "ldap.role.group_delete_failed",
|
||
"department_id": department.pk,
|
||
"error_type": type(error).__name__,
|
||
},
|
||
exc_info=True,
|
||
)
|
||
|
||
try:
|
||
dept_dn = self._get_department_dn(department)
|
||
except DirectoryServiceError:
|
||
dept_dn = None
|
||
self._reconcile_department_group(department, dept_dn)
|
||
|
||
def sync_role_assignment_state(
|
||
self,
|
||
employee: Employee,
|
||
role: DepartmentRole,
|
||
*,
|
||
is_active: bool,
|
||
) -> None:
|
||
"""Синхронизирует LDAP-членство сотрудника в группе роли."""
|
||
self._ensure_runtime_services()
|
||
self._sync_role_membership(employee, role, add=is_active)
|
||
|
||
def set_member_role(self, dept: Department, employee: Employee, role) -> None:
|
||
"""DEPRECATED: Используйте assign_role / revoke_role.
|
||
|
||
Меняет роль участника отдела с синхронизацией LDAP-групп.
|
||
Сохранена для обратной совместимости.
|
||
|
||
Args:
|
||
dept: Отдел.
|
||
employee: Сотрудник.
|
||
role: Новая роль или None.
|
||
|
||
Raises:
|
||
DirectoryDbError: Ошибка при обновлении БД.
|
||
DirectoryLdapError: Ошибка при синхронизации групп.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
# Обновляем 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:
|
||
raise DirectoryDbError("Employee is not a member of this department") from e
|
||
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:
|
||
"""Назначает роль сотруднику (не требует членства в отделе).
|
||
|
||
Логика: сначала LDAP, потом БД. При ошибке LDAP — операция отменяется.
|
||
|
||
Args:
|
||
employee: Сотрудник.
|
||
role: Роль для назначения.
|
||
assigned_by: Кто назначил (опционально).
|
||
|
||
Returns:
|
||
RoleAssignment: Созданное/обновлённое назначение.
|
||
|
||
Raises:
|
||
DirectoryLdapError: Ошибка LDAP.
|
||
DirectoryDbError: Ошибка БД.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
# 1. Сначала синхронизируем LDAP-группу роли
|
||
try:
|
||
self._sync_role_membership(employee, role, add=True)
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP role sync failed: {e}") from e
|
||
|
||
# 2. Только при успехе LDAP — создаём/обновляем назначение в БД
|
||
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:
|
||
# Откатываем LDAP — удаляем из группы
|
||
try:
|
||
self._sync_role_membership(employee, role, add=False)
|
||
except Exception as rollback_error:
|
||
self._logger.warning(
|
||
"LDAP role membership rollback failed",
|
||
extra={
|
||
"event": "ldap.role.membership_rollback_failed",
|
||
"employee_id": employee.pk,
|
||
"role_id": role.pk,
|
||
"error_type": type(rollback_error).__name__,
|
||
},
|
||
exc_info=True,
|
||
)
|
||
raise DirectoryDbError(str(e)) from e
|
||
|
||
return assignment
|
||
|
||
def revoke_role(
|
||
self,
|
||
employee: Employee,
|
||
role: DepartmentRole,
|
||
) -> None:
|
||
"""Отзывает роль у сотрудника.
|
||
|
||
Логика: сначала LDAP, потом БД. При ошибке LDAP — операция отменяется.
|
||
|
||
Args:
|
||
employee: Сотрудник.
|
||
role: Роль для отзыва.
|
||
|
||
Raises:
|
||
DirectoryLdapError: Ошибка LDAP.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
# 1. Сначала удаляем из LDAP-группы
|
||
try:
|
||
self._sync_role_membership(employee, role, add=False)
|
||
except Exception as e:
|
||
raise DirectoryLdapError(f"LDAP role revoke failed: {e}") from e
|
||
|
||
# 2. Только при успехе LDAP — деактивируем назначение
|
||
RoleAssignment.objects.filter(employee=employee, role=role).update(
|
||
is_active=False
|
||
)
|
||
|
||
def _sync_role_membership(
|
||
self,
|
||
employee: Employee,
|
||
role: DepartmentRole,
|
||
add: bool = True,
|
||
) -> None:
|
||
"""Синхронизирует членство сотрудника в группе роли LDAP.
|
||
|
||
Args:
|
||
employee: Сотрудник.
|
||
role: Роль.
|
||
add: True — добавить в группу, False — удалить.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
# Создаём группу роли если нет
|
||
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)
|
||
if add:
|
||
if not (employee.is_active and employee.is_actually_active):
|
||
self._remove_ou_group_member(role.ldap_group_dn, user_dn)
|
||
return
|
||
self._add_ou_group_member(role.ldap_group_dn, user_dn)
|
||
else:
|
||
self._remove_ou_group_member(role.ldap_group_dn, user_dn)
|
||
|
||
def _resolve_member_roles(
|
||
self,
|
||
employee: Employee,
|
||
dept: Department,
|
||
role_hint: Any = None,
|
||
) -> List[DepartmentRole]:
|
||
"""Собирает целевой набор ролей для LDAP sync в рамках одного отдела."""
|
||
if not (employee.is_active and employee.is_actually_active):
|
||
return []
|
||
|
||
roles_by_id: Dict[int, DepartmentRole] = {}
|
||
|
||
def add_role(role_obj: Optional[DepartmentRole]) -> None:
|
||
if (
|
||
role_obj is not None
|
||
and role_obj.department_id == dept.id
|
||
and role_obj.pk not in roles_by_id
|
||
):
|
||
roles_by_id[role_obj.pk] = role_obj
|
||
|
||
active_link = (
|
||
EmployeeDepartment.objects.filter(
|
||
employee_id=employee.id,
|
||
department_id=dept.id,
|
||
is_active=True,
|
||
)
|
||
.select_related("role")
|
||
.first()
|
||
)
|
||
if active_link and active_link.role_id:
|
||
add_role(active_link.role)
|
||
|
||
assigned_roles = DepartmentRole.objects.filter(
|
||
department_id=dept.id,
|
||
assignments__employee_id=employee.id,
|
||
assignments__is_active=True,
|
||
).distinct()
|
||
for assigned_role in assigned_roles:
|
||
add_role(assigned_role)
|
||
|
||
if isinstance(role_hint, DepartmentRole):
|
||
add_role(role_hint)
|
||
elif isinstance(role_hint, int) or (
|
||
isinstance(role_hint, str) and role_hint.isdigit()
|
||
):
|
||
role_obj = DepartmentRole.objects.filter(
|
||
department_id=dept.id,
|
||
pk=int(role_hint),
|
||
).first()
|
||
add_role(role_obj)
|
||
elif isinstance(role_hint, str) and role_hint.strip():
|
||
hint = role_hint.strip()
|
||
role_obj = DepartmentRole.objects.filter(
|
||
department_id=dept.id,
|
||
name=hint,
|
||
).first()
|
||
if role_obj is None and ": " in hint:
|
||
role_name = hint.split(": ", 1)[1].strip()
|
||
role_obj = DepartmentRole.objects.filter(
|
||
department_id=dept.id,
|
||
name=role_name,
|
||
).first()
|
||
add_role(role_obj)
|
||
|
||
return list(roles_by_id.values())
|
||
|
||
def _reconcile_member_role_memberships(
|
||
self,
|
||
employee: Employee,
|
||
dept: Department,
|
||
*,
|
||
is_active: bool,
|
||
role: Any = None,
|
||
) -> None:
|
||
"""Синхронизирует LDAP группы ролей сотрудника по текущему DB state."""
|
||
self._ensure_runtime_services()
|
||
|
||
desired_role_ids = {
|
||
item.pk
|
||
for item in (
|
||
self._resolve_member_roles(employee, dept, role) if is_active else []
|
||
)
|
||
}
|
||
dept_roles = list(
|
||
DepartmentRole.objects.filter(department_id=dept.id).order_by("pk")
|
||
)
|
||
for dept_role in dept_roles:
|
||
self._sync_role_membership(
|
||
employee,
|
||
dept_role,
|
||
add=dept_role.pk in desired_role_ids,
|
||
)
|
||
|
||
def _ensure_role_group(self, role: DepartmentRole) -> str:
|
||
"""Гарантирует наличие группы роли ROLE_<Name> в OU отдела.
|
||
|
||
Args:
|
||
role: Роль отдела.
|
||
|
||
Returns:
|
||
str: DN группы роли.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
dept = role.department
|
||
dept_dn = self._get_department_dn(dept)
|
||
|
||
role_name = self._sanitize_name(role.name)
|
||
expected_cn = f"ROLE_{role_name}"
|
||
expected_dn = f"CN={esc_rdn(expected_cn)},{dept_dn}"
|
||
expected_sam = self._build_role_sam_account_name(dept, role.name)
|
||
|
||
saved_dn = (role.ldap_group_dn or "").strip()
|
||
role_group: Optional[LdapOrganizationalUnitGroup] = None
|
||
|
||
for candidate_dn in (saved_dn, expected_dn):
|
||
if not candidate_dn:
|
||
continue
|
||
try:
|
||
role_group = LdapOrganizationalUnitGroup.objects.get(dn=candidate_dn)
|
||
break
|
||
except LdapOrganizationalUnitGroup.DoesNotExist:
|
||
continue
|
||
|
||
if role_group is None:
|
||
role_group = LdapOrganizationalUnitGroup.objects.create(
|
||
dn=expected_dn,
|
||
cn=expected_cn,
|
||
sam_account_name=expected_sam,
|
||
description=f"Role: {role.name} in {dept.name}",
|
||
)
|
||
else:
|
||
group_changed = False
|
||
_, current_parent = self._split_rdn_parent(str(role_group.dn))
|
||
if current_parent.lower() != dept_dn.lower():
|
||
role_group.base_dn = dept_dn
|
||
group_changed = True
|
||
if role_group.cn != expected_cn:
|
||
role_group.cn = expected_cn
|
||
group_changed = True
|
||
if role_group.sam_account_name != expected_sam:
|
||
role_group.sam_account_name = expected_sam
|
||
group_changed = True
|
||
expected_description = f"Role: {role.name} in {dept.name}"
|
||
if (role_group.description or "") != expected_description:
|
||
role_group.description = expected_description
|
||
group_changed = True
|
||
if group_changed:
|
||
role_group.save()
|
||
|
||
resolved_dn = str(role_group.dn)
|
||
DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn=resolved_dn)
|
||
role.ldap_group_dn = resolved_dn
|
||
return resolved_dn
|
||
|
||
def rename_role_group(self, role: DepartmentRole, new_name: str) -> str:
|
||
"""Переименовывает группу роли в LDAP.
|
||
|
||
Args:
|
||
role: Роль.
|
||
new_name: Новое название роли.
|
||
|
||
Returns:
|
||
str: Новый DN группы.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
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}"
|
||
new_sam = self._build_role_sam_account_name(role.department, new_name)
|
||
|
||
role_group = LdapOrganizationalUnitGroup.objects.get(dn=role.ldap_group_dn)
|
||
role_group.cn = new_cn
|
||
role_group.sam_account_name = new_sam
|
||
role_group.save()
|
||
new_dn = str(role_group.dn)
|
||
|
||
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: Роль для удаления группы.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
if not role.ldap_group_dn:
|
||
return
|
||
|
||
try:
|
||
role_group = LdapOrganizationalUnitGroup.objects.get(dn=role.ldap_group_dn)
|
||
role_group.delete()
|
||
except LdapOrganizationalUnitGroup.DoesNotExist:
|
||
pass
|
||
|
||
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] # Ограничение длины
|
||
|
||
def _build_role_sam_account_name(
|
||
self, department: Department, role_name: str
|
||
) -> str:
|
||
"""Строит детерминированный и глобально-уникальный sAMAccountName.
|
||
|
||
CN роли остаётся человекочитаемым (`ROLE_<Name>`), а sAMAccountName
|
||
должен быть уникален во всём домене и помещаться в legacy-лимит 20.
|
||
"""
|
||
sanitized = self._sanitize_name(role_name).upper() or "ROLE"
|
||
prefix = sanitized[:6]
|
||
digest = (
|
||
hashlib.sha1(f"{department.pk}:{sanitized}".encode("utf-8"))
|
||
.hexdigest()[:10]
|
||
.upper()
|
||
)
|
||
return f"R{prefix}{digest}"[:20]
|
||
|
||
# ==================== DN/Lookup Methods ==================== #
|
||
|
||
def _get_department_dn(self, dept: Department) -> str:
|
||
"""Возвращает DN OU отдела из LdapSyncState.
|
||
|
||
Args:
|
||
dept: Экземпляр отдела.
|
||
|
||
Returns:
|
||
str: Полный DN OU отдела.
|
||
|
||
Raises:
|
||
DirectoryServiceError: Если DN отсутствует.
|
||
"""
|
||
dn = (
|
||
LdapSyncState.objects.filter(model="department", object_pk=str(dept.pk))
|
||
.values_list("ldap_dn", flat=True)
|
||
.first()
|
||
)
|
||
if not dn:
|
||
raise DirectoryServiceError("Department has no ldap_dn in sync state")
|
||
return dn
|
||
|
||
def _get_department_by_dn(self, dept_dn: str) -> Optional[Department]:
|
||
"""Возвращает Department по DN из LdapSyncState.
|
||
|
||
Args:
|
||
dept_dn: DN OU отдела.
|
||
|
||
Returns:
|
||
Optional[Department]: Найденный отдел или None.
|
||
"""
|
||
if not dept_dn:
|
||
return None
|
||
pk = (
|
||
LdapSyncState.objects.filter(model="department", ldap_dn__iexact=dept_dn)
|
||
.values_list("object_pk", flat=True)
|
||
.first()
|
||
)
|
||
if not pk:
|
||
return None
|
||
return Department.objects.filter(pk=pk).first()
|
||
|
||
# ==================== Private OU Methods ==================== #
|
||
|
||
def _get_department_ou(self, dept_dn: str) -> LdapOrganizationalUnit:
|
||
"""Возвращает high-level LDAP модель OU отдела по DN."""
|
||
try:
|
||
return LdapOrganizationalUnit.objects.get(dn=dept_dn)
|
||
except LdapOrganizationalUnit.DoesNotExist as e:
|
||
raise DirectoryServiceError(f"OU not found: {dept_dn}") from e
|
||
|
||
def _ensure_department_ou(self, name: str) -> str:
|
||
"""Гарантирует наличие OU отдела через ORM-модель."""
|
||
base = getattr(settings, "LDAP_DEPARTMENTS_BASE", None)
|
||
if not base:
|
||
raise RuntimeError("LDAP_DEPARTMENTS_BASE is not configured")
|
||
dn = f"OU={name},{base}"
|
||
try:
|
||
ou = LdapOrganizationalUnit.objects.get(dn=dn)
|
||
except LdapOrganizationalUnit.DoesNotExist:
|
||
ou = LdapOrganizationalUnit.objects.create(
|
||
dn=dn,
|
||
ou=name,
|
||
description="",
|
||
managed_by="",
|
||
)
|
||
return str(ou.dn)
|
||
|
||
def _rename_department_ou(self, dept_dn: str, new_name: str) -> str:
|
||
"""Переименовывает OU отдела через ModifyDnMixin."""
|
||
ou = self._get_department_ou(dept_dn)
|
||
ou.ou = new_name
|
||
ou.save()
|
||
return str(ou.dn)
|
||
|
||
def _set_ou_managed_by(self, dept_dn: str, head_dn: Optional[str]) -> None:
|
||
"""Устанавливает/очищает managedBy у OU в AD через ORM.
|
||
|
||
Args:
|
||
dept_dn: DN OU отдела.
|
||
head_dn: DN руководителя или None.
|
||
"""
|
||
if not dept_dn:
|
||
raise DirectoryServiceError("dept_dn пуст")
|
||
|
||
ou = self._get_department_ou(dept_dn)
|
||
|
||
current = (ou.managed_by or "").strip().lower()
|
||
target = (head_dn or "").strip().lower()
|
||
|
||
if current == target:
|
||
return
|
||
|
||
ou.managed_by = head_dn or ""
|
||
ou.save()
|
||
|
||
def _set_ou_description(self, dept_dn: str, description: Optional[str]) -> None:
|
||
"""Ставит/очищает description у OU (ORM).
|
||
|
||
Args:
|
||
dept_dn: DN OU отдела.
|
||
description: Описание или None для удаления.
|
||
"""
|
||
try:
|
||
ou = LdapOrganizationalUnit.objects.get(dn=dept_dn)
|
||
ou.description = description or ""
|
||
ou.save()
|
||
except LdapOrganizationalUnit.DoesNotExist:
|
||
pass
|
||
|
||
def _delete_department_ou(self, dept_dn: str) -> None:
|
||
"""Удаляет OU (ORM, ignore DoesNotExist).
|
||
|
||
Args:
|
||
dept_dn: DN OU для удаления.
|
||
"""
|
||
try:
|
||
ou = LdapOrganizationalUnit.objects.get(dn=dept_dn)
|
||
ou.delete()
|
||
except LdapOrganizationalUnit.DoesNotExist:
|
||
pass
|
||
|
||
def _evict_all_users_from_department_ou(self, dept_dn: str) -> None:
|
||
"""Перемещает всех пользователей из OU отдела через LdapUser ORM.
|
||
|
||
Args:
|
||
dept_dn: DN OU отдела.
|
||
|
||
Raises:
|
||
RuntimeError: Если не настроен LDAP_USERS_BASE.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
suffix = f",{dept_dn}".lower()
|
||
states = list(
|
||
LdapSyncState.objects.filter(
|
||
model="employee",
|
||
ldap_dn__iendswith=suffix,
|
||
).exclude(ldap_dn="")
|
||
)
|
||
for state in states:
|
||
employee = Employee.objects.filter(pk=state.object_pk).first()
|
||
if employee is None:
|
||
continue
|
||
target_base = get_base_dn_for_employee(employee)
|
||
self._move_employee_to_base_dn(
|
||
employee,
|
||
target_base,
|
||
current_dn=state.ldap_dn,
|
||
)
|
||
|
||
# ==================== Private Group Methods ==================== #
|
||
|
||
def _ensure_department_group(self, dept: Department, dept_dn: str) -> str:
|
||
"""Гарантирует наличие группы отдела через ORM-модель."""
|
||
self._ensure_runtime_services()
|
||
|
||
name = (dept.name or "").strip()
|
||
if not name:
|
||
raise ValueError("Department.name is empty")
|
||
|
||
expected_cn = f"DEP_{name}"
|
||
expected_dn = f"CN={esc_rdn(expected_cn)},{dept_dn}"
|
||
expected_description = f"{name} department members"
|
||
saved_dn = (dept.ldap_group_dn or "").strip()
|
||
|
||
candidate_dns: List[str] = []
|
||
for candidate in (
|
||
saved_dn,
|
||
(
|
||
f"{saved_dn.split(',', 1)[0]},{dept_dn}"
|
||
if saved_dn and "," in saved_dn
|
||
else ""
|
||
),
|
||
expected_dn,
|
||
):
|
||
if candidate and candidate not in candidate_dns:
|
||
candidate_dns.append(candidate)
|
||
|
||
group: Optional[LdapOrganizationalUnitGroup] = None
|
||
for candidate_dn in candidate_dns:
|
||
try:
|
||
group = LdapOrganizationalUnitGroup.objects.get(dn=candidate_dn)
|
||
break
|
||
except LdapOrganizationalUnitGroup.DoesNotExist:
|
||
continue
|
||
|
||
if group is None:
|
||
group = LdapOrganizationalUnitGroup.objects.create(
|
||
dn=expected_dn,
|
||
cn=expected_cn,
|
||
sam_account_name=expected_cn[:20],
|
||
description=expected_description,
|
||
)
|
||
else:
|
||
group_changed = False
|
||
_, current_parent = self._split_rdn_parent(str(group.dn))
|
||
if current_parent.lower() != dept_dn.lower():
|
||
group.base_dn = dept_dn
|
||
group_changed = True
|
||
if group.cn != expected_cn:
|
||
group.cn = expected_cn
|
||
group_changed = True
|
||
if (group.description or "") != expected_description:
|
||
group.description = expected_description
|
||
group_changed = True
|
||
if group_changed:
|
||
group.save()
|
||
|
||
group_dn = str(group.dn)
|
||
if dept.pk:
|
||
Department.objects.filter(pk=dept.pk).update(ldap_group_dn=group_dn)
|
||
dept.ldap_group_dn = group_dn
|
||
return group_dn
|
||
|
||
def _reconcile_department_group(
|
||
self, dept: Department, dept_dn: Optional[str]
|
||
) -> str:
|
||
"""Приводит состав группы отдела к активным EmployeeDepartment.
|
||
|
||
Args:
|
||
dept: Модель отдела.
|
||
dept_dn: DN OU отдела (если None, будет запрошен).
|
||
|
||
Returns:
|
||
str: DN группы отдела.
|
||
"""
|
||
self._ensure_runtime_services()
|
||
|
||
dept_dn = dept_dn or self._get_department_dn(dept)
|
||
group_dn = self._ensure_department_group(dept, dept_dn)
|
||
group = LdapOrganizationalUnitGroup.objects.get(dn=group_dn)
|
||
|
||
active_links = EmployeeDepartment.objects.filter(
|
||
department_id=dept.id,
|
||
is_active=True,
|
||
).select_related("employee")
|
||
active_emp_ids = [
|
||
link.employee_id
|
||
for link in active_links
|
||
if link.employee.is_active and link.employee.is_actually_active
|
||
]
|
||
role_group_dns = [
|
||
item
|
||
for item in DepartmentRole.objects.filter(
|
||
department_id=dept.id,
|
||
)
|
||
.exclude(ldap_group_dn="")
|
||
.values_list("ldap_group_dn", flat=True)
|
||
if item
|
||
]
|
||
if not active_emp_ids and not role_group_dns:
|
||
group.member = []
|
||
group.save()
|
||
return group_dn
|
||
|
||
member_dns: List[str] = []
|
||
if active_emp_ids:
|
||
dn_map = dict(
|
||
LdapSyncState.objects.filter(
|
||
model="employee",
|
||
object_pk__in=[str(i) for i in active_emp_ids],
|
||
).values_list("object_pk", "ldap_dn")
|
||
)
|
||
member_dns.extend(dn for dn in dn_map.values() if dn)
|
||
member_dns.extend(role_group_dns)
|
||
|
||
deduped_member_dns = list(dict.fromkeys(member_dns))
|
||
group.member = deduped_member_dns
|
||
group.save()
|
||
return group_dn
|
||
|
||
# ==================== Utility Methods ==================== #
|
||
|
||
def _split_rdn_parent(self, dn: str) -> Tuple[str, str]:
|
||
"""Делит DN на (RDN, parentDN).
|
||
|
||
Args:
|
||
dn: Distinguished Name.
|
||
|
||
Returns:
|
||
Tuple[str, str]: (RDN, parent DN).
|
||
|
||
Raises:
|
||
DirectoryServiceError: Если DN некорректный.
|
||
"""
|
||
parts = dn.split(",", 1)
|
||
if len(parts) != 2:
|
||
raise DirectoryServiceError(f"Malformed DN: {dn}")
|
||
return parts[0], parts[1]
|
||
|
||
def _sync_descendant_dns_after_ou_rename(
|
||
self, *, old_dn: str, new_dn: str
|
||
) -> Tuple[int, int]:
|
||
"""Переписывает хвост DN у sync-state сотрудников и role group DN.
|
||
|
||
Вызывается после переименования OU отдела.
|
||
|
||
Args:
|
||
old_dn: Старый DN OU.
|
||
new_dn: Новый DN OU.
|
||
|
||
Returns:
|
||
Tuple[int, int]: (обновлено role group DN,
|
||
обновлено employee sync-state).
|
||
"""
|
||
old_suffix = f",{old_dn}".lower()
|
||
new_suffix = f",{new_dn}"
|
||
|
||
role_updated_count = 0
|
||
roles = list(DepartmentRole.objects.filter(ldap_group_dn__iendswith=old_suffix))
|
||
for role in roles:
|
||
old_role_dn = role.ldap_group_dn or ""
|
||
if not old_role_dn.lower().endswith(old_suffix):
|
||
continue
|
||
rdn = old_role_dn[: -len(old_suffix)]
|
||
role.ldap_group_dn = rdn + new_suffix
|
||
role.save(update_fields=["ldap_group_dn"])
|
||
role_updated_count += 1
|
||
|
||
states = list(
|
||
LdapSyncState.objects.filter(
|
||
model="employee", ldap_dn__iendswith=old_suffix
|
||
)
|
||
)
|
||
if not states:
|
||
return role_updated_count, 0
|
||
|
||
now = timezone.now()
|
||
updated_count = 0
|
||
for st in states:
|
||
old_st_dn = st.ldap_dn or ""
|
||
if not old_st_dn.lower().endswith(old_suffix):
|
||
continue
|
||
rdn = old_st_dn[: -len(old_suffix)]
|
||
new_st_dn = rdn + new_suffix
|
||
st.touch(
|
||
ldap_dn=new_st_dn,
|
||
last_django_modify_ts=now,
|
||
sync_dir=SyncDirection.AUTO,
|
||
)
|
||
updated_count += 1
|
||
return role_updated_count, updated_count
|
||
|
||
|
||
__all__ = ["DepartmentService"]
|