2026-09-07 19:43:48 +03:00

918 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сервис для управления пользователями в LDAP и Django.
Этот модуль содержит бизнес-логику для CRUD операций с пользователями,
включая синхронизацию между Active Directory и Django ORM.
Рефакторенная версия с улучшениями:
- Наследуется от BaseService (логирование, _touch_state)
- Делегирует работу подсервисам (Password, Login, Mapper)
- Использует константы из constants.py
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Iterable, List, Optional
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from ldap3 import Connection
from ...models import Employee, LdapSyncState
from ..domain.dtos import DirectoryUserDTO
from ..errors import (
DirectoryDbError,
DirectoryLdapError,
DirectoryServiceError,
)
from ..infrastructure.connections import _ldap
from ..orm_models import LdapUser
from ..repositories.ldap_repository import (
ensure_container_exists,
)
from ..utils.group_utils_orm import sync_user_groups_by_cns_orm
from ..utils.ldap_utils import cn_candidates, get_ldap_str
from ..utils.text_utils import esc_rdn
from .base_service import BaseService
from .constants import (
LdapErrorCode,
SyncDirection,
UserAccountControl,
)
from .user_login_service import UserLoginService
from .user_mapper_service import UserMapperService
from .user_password_service import UserPasswordService
logger = logging.getLogger(__name__)
class UserService(BaseService):
"""Сервис для управления пользователями в LDAP и Django.
Координирует работу подсервисов:
- UserPasswordService — пароли
- UserLoginService — генерация логинов
- UserMapperService — маппинг атрибутов
"""
def __init__(
self,
ldap_repository=None,
employee_repository=None,
sync_state_repository=None,
):
"""
Инициализация сервиса.
Args:
ldap_repository: Репозиторий для работы с LDAP (optional)
employee_repository: Репозиторий для работы с Employee (optional)
sync_state_repository: Репозиторий для LdapSyncState (optional)
"""
super().__init__()
self.ldap_repository = ldap_repository
self.employee_repository = employee_repository
self.sync_state_repository = sync_state_repository
# Подсервисы
self._password_service = UserPasswordService()
self._login_service = UserLoginService()
self._mapper_service = UserMapperService()
def create_user(self, dto: DirectoryUserDTO) -> Employee:
"""Создаёт учётку в LDAP и запись в БД.
DN и связь с LDAP хранятся только в LdapSyncState.
Процесс:
1) LDAP: create → set password → optional enable.
2) DB (atomic): создаём Employee и фиксируем DN
в LdapSyncState(model='employee').
3) Post-actions: группы, аватар (best-effort).
Args:
dto (DirectoryUserDTO): Данные нового пользователя.
Returns:
Employee: Созданный сотрудник.
Raises:
DirectoryLdapError: Ошибка на этапе создания
или настройки LDAP.
DirectoryDbError: Ошибка записи в БД.
В этом случае выполняется компенсирующее удаление в LDAP.
"""
with _ldap() as conn:
dn: Optional[str] = None
try:
# 1) LDAP
dn = self._create_user_in_ldap(conn, dto)
self._password_service.set_password(conn, dn, dto.initial_password)
if dto.is_active:
self._enable_user(conn, dn)
except Exception as e:
raise DirectoryLdapError(f"LDAP create failed: {e}") from e
# 2) DB + sync-state
try:
with transaction.atomic():
# Проверяем, существует ли уже пользователь с таким email
emp = Employee.objects.filter(email=dto.email).first()
if emp:
# Обновляем существующего пользователя
emp.first_name = dto.first_name
emp.last_name = dto.last_name
emp.phone_number = dto.phone_e164
emp.is_active = dto.is_active
emp.is_ldap_managed = True
emp._skip_ldap_sync = True
emp.save()
else:
# Создаем нового пользователя
emp = Employee.objects.create(
first_name=dto.first_name,
last_name=dto.last_name,
email=dto.email,
phone_number=dto.phone_e164,
is_active=dto.is_active,
is_ldap_managed=True,
)
if hasattr(emp, "set_unusable_password"):
emp.set_unusable_password()
emp._skip_ldap_sync = True
emp.save(update_fields=["password"])
# Запись employeeNumber в LDAP для связки через прямую
# модификацию. Используем ldap3 напрямую, так как ORM
# .save() пытается перестроить DN.
from ldap3 import MODIFY_REPLACE
conn.modify(
dn,
{"employeeNumber": [(MODIFY_REPLACE, [str(emp.pk)])]},
)
guid = None
self._touch_state(
model="employee",
object_pk=emp.pk,
ldap_dn=dn,
ldap_guid=guid,
sync_dir=SyncDirection.LDAP,
last_django_modify_ts=timezone.now(),
)
# TODO: Implement department assignment
# DirectoryService был удален, требуется рефакторинг
# if dto.department_dn and hasattr(
# emp, "set_active_department"
# ):
# dept = Department.objects.filter(
# ldap_group_dn=dto.department_dn
# ).first()
# if dept:
# emp.set_active_department(dept)
self._log_operation(
"create",
model="employee",
object_id=emp.pk,
dn=dn,
success=True,
)
except Exception as e:
try:
if dn:
self._hard_delete_user_in_ldap(conn, dn)
finally:
pass
raise DirectoryDbError(str(e)) from e
# 3) Post-actions (best effort)
try:
if dto.group_cns:
sync_user_groups_by_cns_orm(dn, set(dto.group_cns))
if dto.avatar_bytes:
avatar = self._mapper_service.process_avatar(dto.avatar_bytes)
if avatar:
ldap_user_av = LdapUser.objects.get(dn=dn)
ldap_user_av.thumbnail_photo = avatar
ldap_user_av.save()
except Exception:
self._logger.warning(
"LDAP user post-actions failed",
extra={
"event": "ldap.user.post_actions_failed",
"employee_id": emp.pk,
},
exc_info=True,
)
return emp
def update_user(
self,
emp: Employee,
changes: Dict[str, Any],
group_cns: Optional[List[str]] = None,
move_to_department_dn: Optional[str] = None,
) -> Employee:
"""Обновляет пользователя: сначала LDAP, затем БД.
Args:
emp (Employee): Сотрудник, которого обновляем.
changes (Dict[str, Any]): Поля модели: first_name,
last_name, email, phone_number, is_active, password,
avatar_bytes, display_name и т.п.
group_cns (Optional[List[str]]): Полный набор CN для
синхронизации членства или None.
move_to_department_dn (Optional[str]): DN OU,
куда переместить запись.
Returns:
Employee: Обновлённая модель.
Raises:
DirectoryLdapError: Ошибка применения изменений в LDAP.
DirectoryDbError: Ошибка фиксации изменений в БД.
DirectoryServiceError: Нет DN пользователя.
"""
current_dn = self._get_employee_dn(emp)
is_active_val: Optional[bool] = None
if "is_active" in changes:
try:
is_active_val = bool(changes.get("is_active"))
except Exception:
is_active_val = None
# Извлекаем position из changes — обработаем отдельно
new_position = changes.pop("position", None)
new_position_id = changes.pop("position_id", None)
old_position = emp.position
with _ldap() as conn:
ldap_changes = dict(changes)
try:
result = self._update_user_in_ldap(
conn=conn,
current_dn=current_dn,
model_changes=ldap_changes,
move_to_department_dn=move_to_department_dn,
group_cns=group_cns,
emp_pk=emp.pk, # Передаём PK для поиска по employeeNumber
)
# Распаковываем результат (dn, avatar_bytes или None)
if isinstance(result, tuple):
new_dn, saved_avatar_bytes = result
else:
# Обратная совместимость (если метод вернул только dn)
new_dn = result
saved_avatar_bytes = None
except Exception as e:
raise DirectoryLdapError(f"LDAP update failed: {e}") from e
try:
with transaction.atomic():
# обновим sync-state, если DN поменялся
if new_dn and new_dn != current_dn:
self._touch_state(
model="employee",
object_pk=emp.pk,
ldap_dn=new_dn,
sync_dir=SyncDirection.LDAP,
last_django_modify_ts=timezone.now(),
)
current_dn = new_dn
# синхронизация поля is_active
updated_fields: List[str] = []
if (
is_active_val is not None
and getattr(emp, "is_active", None) != is_active_val
):
emp.is_active = is_active_val
updated_fields.append("is_active")
for k, v in changes.items():
if k in {"password", "avatar_bytes", "is_active"}:
continue
if hasattr(emp, k) and getattr(emp, k) != v:
setattr(emp, k, v)
updated_fields.append(k)
# Обновляем position в DB
if new_position_id is not None:
if emp.position_id != new_position_id:
emp.position_id = new_position_id
updated_fields.append("position_id")
elif new_position is not None:
pid = new_position.pk if new_position else None
if emp.position_id != pid:
emp.position_id = pid
updated_fields.append("position_id")
if move_to_department_dn and hasattr(emp, "set_active_department"):
from .department_service import DepartmentService
dept_svc = DepartmentService()
dept = dept_svc._get_department_by_dn(move_to_department_dn)
if dept:
emp.set_active_department(dept)
# Сохраняем аватар в БД, если он был успешно записан в LDAP
if saved_avatar_bytes and hasattr(emp, "avatar"):
from django.core.files.base import ContentFile
filename = f"avatar_{emp.pk}.jpg"
emp.avatar.save(
filename,
ContentFile(saved_avatar_bytes),
save=False,
)
if "avatar" not in updated_fields:
updated_fields.append("avatar")
if updated_fields:
# Устанавливаем флаг чтобы не запустить LDAP sync
# повторно
emp._skip_ldap_sync = True
emp.save(update_fields=list(set(updated_fields)))
except Exception as e:
raise DirectoryDbError(str(e)) from e
# Position group membership sync (best-effort)
self._sync_position_groups(
emp,
old_position,
new_position,
new_position_id,
)
return emp
def delete_user(self, emp: Employee) -> None:
"""Удаляет пользователя.
Порядок: LDAP soft-disable -> DB delete -> LDAP hard delete.
Args:
emp (Employee): Удаляемый сотрудник.
Raises:
DirectoryLdapError: Ошибка при soft-disable/hard-delete.
DirectoryDbError: Ошибка при удалении записи в БД.
"""
try:
dn: Optional[str] = self._get_employee_dn(emp)
except DirectoryServiceError:
dn = None
# ORM: soft-disable (UAC = disabled + password expired)
if dn:
try:
ldap_user = LdapUser.objects.get(dn=dn)
ldap_user.user_account_control = (
UserAccountControl.DISABLED_PASSWORD_EXPIRED
)
ldap_user.save()
self._log_operation(
"soft_disable",
model="employee",
object_id=emp.pk,
dn=dn,
success=True,
)
except LdapUser.DoesNotExist:
self._logger.warning(
"LDAP user already deleted",
extra={
"event": "ldap.user.already_deleted",
"employee_id": emp.pk,
},
)
except Exception as e:
raise DirectoryLdapError(f"LDAP soft-disable failed: {e}") from e
try:
with transaction.atomic():
emp_pk = emp.pk
emp.delete()
LdapSyncState.objects.filter(
model="employee", object_pk=str(emp_pk)
).delete()
self._log_operation(
"delete",
model="employee",
object_id=emp_pk,
dn=dn,
success=True,
)
except Exception as e:
raise DirectoryDbError(str(e)) from e
# ORM: hard delete
if dn:
try:
ldap_user = LdapUser.objects.get(dn=dn)
ldap_user.delete()
self._log_operation(
"hard_delete",
model="employee",
object_id=emp_pk,
dn=dn,
success=True,
)
except LdapUser.DoesNotExist:
self._logger.info(
"LDAP user already deleted",
extra={
"event": "ldap.user.already_deleted",
"employee_id": emp_pk,
},
)
except Exception as e:
raise DirectoryLdapError(f"LDAP hard delete failed: {e}") from e
# ==================== DN ↔ ID Conversion Methods ==================== #
def employee_ids_to_dns(self, ids: list[int]) -> list[str]:
"""Конвертирует ID сотрудников в их Distinguished Names.
Args:
ids: Список ID сотрудников.
Returns:
Список DN из LdapSyncState.
"""
if not ids:
return []
id_strs = [str(i) for i in ids if isinstance(i, int)]
return list(
LdapSyncState.objects.filter(model="employee", object_pk__in=id_strs)
.exclude(ldap_dn__isnull=True)
.exclude(ldap_dn__exact="")
.values_list("ldap_dn", flat=True)
)
def dns_to_employee_ids(self, dns: list[str]) -> list[int]:
"""Конвертирует Distinguished Names в ID сотрудников.
Args:
dns: Список Distinguished Names.
Returns:
Список ID сотрудников.
"""
if not dns:
return []
ids = list(
LdapSyncState.objects.filter(model="employee", ldap_dn__in=dns).values_list(
"object_pk", flat=True
)
)
# object_pk хранится как str
return [int(x) for x in ids if str(x).isdigit()]
def employees_brief_by_dns(self, dns: list[str]) -> list[dict]:
"""Получает краткую информацию о сотрудниках по их DN.
Args:
dns: Список Distinguished Names.
Returns:
Список словарей с полями: id, email, first_name, last_name.
"""
ids = self.dns_to_employee_ids(dns)
return list(
Employee.objects.filter(id__in=ids).values(
"id", "email", "first_name", "last_name"
)
)
# ==================== Private Helper Methods ==================== #
def _set_password(self, conn: Connection, dn: str, new_password: str) -> None:
"""Делегирует в UserPasswordService."""
self._password_service.set_password(conn, dn, new_password)
def _enable_user(self, conn: Connection, dn: str) -> None:
"""Включает учётку (UAC=ENABLED) через низкоуровневую модификацию."""
from ldap3 import MODIFY_REPLACE
conn.modify(
dn,
{
"userAccountControl": [
(MODIFY_REPLACE, [str(UserAccountControl.ENABLED)])
]
},
)
if not conn.result["result"] == 0:
raise DirectoryLdapError(
f"Failed to enable user {dn}: {conn.result['description']}"
)
def _unique_logins(
self,
conn: Connection,
dto: DirectoryUserDTO,
upn_suffix: str,
) -> tuple[str, str]:
"""Делегирует в UserLoginService."""
return self._login_service.generate_unique_logins(
first_name=dto.first_name,
last_name=dto.last_name,
email=dto.email,
upn_suffix=upn_suffix,
ldap_guid=getattr(dto, "ldap_guid", None),
)
def _phone_write_attr(self) -> str:
"""Делегирует в UserMapperService."""
return self._mapper_service._get_phone_write_attribute()
def _build_user_attrs(
self,
dto: DirectoryUserDTO,
sam: str,
upn: str,
cn_text: str,
) -> Dict[str, Any]:
"""Делегирует в UserMapperService."""
return self._mapper_service.build_creation_attributes(dto, sam, upn, cn_text)
def _try_add_with_cn_list(
self,
conn: Connection,
base_dn: str,
object_classes: List[str],
dto: DirectoryUserDTO,
sam: str,
upn: str,
cns: List[str],
) -> Optional[str]:
"""Пробует создать запись по списку CN-кандидатов."""
if not isinstance(cns, list):
raise TypeError("cns должен быть списком строк")
for cn_txt in cns:
rdn = f"CN={esc_rdn(cn_txt)}"
dn_try = f"{rdn},{base_dn}"
attrs = self._build_user_attrs(dto, sam, upn, cn_txt)
if conn.add(dn_try, object_classes, attrs):
return dn_try
return None
def _sync_position_groups(
self,
emp: Employee,
old_position,
new_position,
new_position_id,
) -> None:
"""Синхронизирует членство в POS-группах при смене должности.
Best-effort: ошибки логируются, но не прерывают
обновление пользователя.
"""
from ...models import Position
from .group_service import GroupService
from .position_service import PositionService
# Определяем новую должность
if new_position is None and new_position_id is not None:
try:
new_position = Position.objects.get(
pk=new_position_id,
)
except Position.DoesNotExist:
new_position = None
if new_position is None and new_position_id is None:
# position не передавался в changes
return
if old_position == new_position:
return
try:
pos_svc = PositionService(
group_service=GroupService(),
user_service=self,
)
if old_position and old_position.ldap_group_dn:
pos_svc.unassign_position(emp, old_position)
if new_position and new_position.ldap_group_dn:
pos_svc.assign_position(emp, new_position)
except Exception as error:
logger.warning(
"LDAP employee position group sync failed",
exc_info=True,
extra={
"event": "ldap.employee.position_group_sync_failed",
"employee_id": emp.pk,
"old_position_id": getattr(old_position, "pk", None),
"new_position_id": getattr(new_position, "pk", None),
"error_type": type(error).__name__,
},
)
def _create_user_in_ldap(self, conn: Connection, dto: DirectoryUserDTO) -> str:
"""Создаёт объект user в LDAP (AD) и возвращает его DN.
NOTE: Используется low-level ldap3 (conn.add) из-за необходимости:
- Перебора вариантов CN при коллизиях имён
- Проверки/создания контейнера (ensure_container_exists)
django-ldapdb ORM не поддерживает эти сценарии.
"""
base_dn = (
dto.department_dn
or getattr(settings, "LDAP_USERS_BASE", None)
or getattr(settings, "LDAP_BASE_DN", None)
)
if not base_dn:
raise RuntimeError(
"Не задан контейнер для создания пользователя "
"(department_dn / LDAP_USERS_BASE / LDAP_BASE_DN)."
)
ensure_container_exists(conn, base_dn)
upn_suffix = getattr(settings, "LDAP_UPN_SUFFIX", "") or (
dto.email.split("@", 1)[1] if dto.email and "@" in dto.email else ""
)
if not upn_suffix:
raise ValueError(
"Не задан UPN-суффикс (LDAP_UPN_SUFFIX) и его нельзя вывести из email."
)
sam, upn = self._unique_logins(conn, dto, upn_suffix)
pretty_cn = (
" ".join(filter(None, [dto.first_name, dto.last_name])).strip()
) or sam
safe_cn = sam
cn_list = cn_candidates(pretty_cn, safe_cn)
object_classes = self._mapper_service.get_object_classes_for_user()
dn = self._try_add_with_cn_list(
conn, base_dn, object_classes, dto, sam, upn, cn_list
)
if dn:
return dn
raise RuntimeError(f"LDAP add user failed: {conn.result}")
def _hard_delete_user_in_ldap(self, conn: Connection, dn: str) -> None:
"""Безусловное удаление user (ignore noSuchObject)."""
ok = conn.delete(dn)
if not ok:
desc = (conn.result or {}).get("description", "")
if desc not in {LdapErrorCode.NO_SUCH_OBJECT}:
raise RuntimeError(f"LDAP delete failed for {dn}: {conn.result}")
self._logger.info(
"LDAP user already deleted",
extra={
"event": "ldap.user.already_deleted",
"has_ldap_dn": bool(dn),
},
)
def _update_user_in_ldap(
self,
conn: Connection,
current_dn: str,
model_changes: Dict[str, Any],
*,
move_to_department_dn: Optional[str] = None,
group_cns: Optional[Iterable[str]] = None,
emp_pk: Optional[int] = None,
# Employee PK для поиска по employeeNumber.
) -> str:
"""Применяет LDAP-изменения к существующему пользователю.
Возвращает возможный новый DN.
NOTE: Этот метод слишком большой (~150 строк) и должен быть
рефакторен в будущем.
Сейчас просто перенесём как есть для совместимости.
Args:
conn: LDAP соединение
current_dn: Текущий DN пользователя (может быть устаревшим)
model_changes: Изменения полей
move_to_department_dn: DN для перемещения
group_cns: Список групп
emp_pk: PK Employee для поиска по employeeNumber если DN устарел
"""
# TODO: DirectoryService был удален, требуется рефакторинг
if not isinstance(current_dn, str) or not current_dn.strip():
raise TypeError("current_dn должен быть непустой строкой")
if not isinstance(model_changes, dict):
raise TypeError("model_changes должен быть словарём")
dn = current_dn
new_password = model_changes.pop("password", None)
avatar_bytes = model_changes.pop("avatar_bytes", None)
is_active_val = model_changes.pop("is_active", None)
# 1) Пароль
if new_password:
self._set_password(conn, dn, new_password)
# 2) Перемещение между OU — обработается в ORM save(),
# см. шаг 3-5 ниже (ldap_user.base_dn = ...)
# 3-5) ORM: UAC + Атрибуты + Аватар (batch через один save)
try:
ldap_user = LdapUser.objects.get(dn=dn)
except LdapUser.DoesNotExist:
# DN неверный - пытаемся найти пользователя по employeeNumber
if emp_pk:
logger.warning(
"LDAP user DN is stale; searching by employee number",
extra={
"event": "ldap.user.stale_dn_detected",
"employee_id": emp_pk,
},
)
try:
# Поиск через filter с employeeNumber
users = LdapUser.objects.filter(employee_number=str(emp_pk))
if users:
ldap_user = users[0]
dn = ldap_user.dn # Обновляем DN на реальный
logger.info(
"LDAP user found using employee number",
extra={
"event": "ldap.user.stale_dn_recovered",
"employee_id": emp_pk,
},
)
else:
raise DirectoryLdapError(
f"User not found by employeeNumber={emp_pk}. "
"User may have been deleted from LDAP."
)
except Exception as error:
logger.error(
"LDAP user fallback lookup failed",
extra={
"event": "ldap.user.fallback_lookup_failed",
"employee_id": emp_pk,
"error_type": type(error).__name__,
},
)
raise DirectoryLdapError(
f"User not found at DN={dn} and search by employeeNumber failed"
) from error
else:
raise DirectoryLdapError(
f"User not found at DN={dn}. DN in database doesn't "
"match LDAP. Please run LDAP sync to fix sync state."
)
orm_dirty = False
# 2) Перемещение между OU через ORM
# ModifyDnMixin.save() автоматически выполнит modify_dn
if move_to_department_dn:
ldap_user.base_dn = move_to_department_dn
orm_dirty = True
# 3) UAC
if is_active_val is not None:
uac_val = (
UserAccountControl.ENABLED
if is_active_val
else UserAccountControl.DISABLED
)
ldap_user.user_account_control = uac_val
orm_dirty = True
# 4) Прочие атрибуты
phone_write = self._phone_write_attr()
orm_field_map = {
"first_name": "given_name",
"last_name": "sn",
"email": "mail",
"phone_number": (
"mobile" if phone_write == "mobile" else "telephone_number"
),
"display_name": "display_name",
}
# Запоминаем старый cn для проверки изменения
old_cn = ldap_user.cn
for model_key, orm_field in orm_field_map.items():
if model_key in model_changes:
val = model_changes[model_key]
if val or orm_field == "sn":
setattr(ldap_user, orm_field, val if val else ".")
orm_dirty = True
# Вычисляем новый cn если изменились имя/фамилия
new_cn = None
if "first_name" in model_changes or "last_name" in model_changes:
parts = []
given_name_str = get_ldap_str(ldap_user.given_name).strip()
sn_str = get_ldap_str(ldap_user.sn).strip()
if given_name_str:
parts.append(given_name_str)
if sn_str and sn_str != ".":
parts.append(sn_str)
new_cn = (
" ".join(parts) if parts else get_ldap_str(ldap_user.sam_account_name)
)
if new_cn != old_cn:
# Устанавливаем cn и displayName —
# ModifyDnMixin.save() автоматически обработает
# rename CN через modify_dn (не modify_s)
ldap_user.cn = new_cn
ldap_user.display_name = new_cn
orm_dirty = True
# 5) Аватар
avatar_saved = False
if avatar_bytes:
avatar = self._mapper_service.process_avatar(avatar_bytes)
if avatar:
ldap_user.thumbnail_photo = avatar
orm_dirty = True
avatar_saved = True
if orm_dirty:
# ModifyDnMixin.save() автоматически:
# 1) Откатит cn к старому значению
# 2) Сохранит остальные атрибуты через modify_s
# 3) Переименует DN через modify_dn
ldap_user.save()
# Обновляем dn после возможного rename
dn = ldap_user.dn
# 6) Группы (ORM)
if group_cns is not None:
sync_user_groups_by_cns_orm(dn, set(group_cns))
# Возвращаем dn и флаг сохранения аватара
return (dn, avatar_bytes if avatar_saved else None)
def _get_employee_dn(self, employee: Employee) -> str:
"""Возвращает DN сотрудника из LdapSyncState или модели.
Args:
employee: Экземпляр Employee.
Returns:
str: Полный DN пользователя в LDAP.
Raises:
DirectoryServiceError: Если DN не найден.
"""
# Сначала проверяем LdapSyncState
dn = (
LdapSyncState.objects.filter(model="employee", object_pk=str(employee.pk))
.values_list("ldap_dn", flat=True)
.first()
)
# Если не найден в LdapSyncState, проверяем поле модели
if not dn and hasattr(employee, "ldap_dn"):
dn = employee.ldap_dn
if not dn:
raise DirectoryServiceError("Employee has no ldap_dn")
return dn
def _move_user_to_base(self, conn: Connection, user_dn: str, base_dn: str) -> str:
"""Перемещает пользовательский объект в указанный контейнер.
NOTE: Используется low-level ldap3 для атомарного move-only
(без обновления атрибутов). ModifyDnMixin решает ту же задачу
через ORM (user.base_dn = ...; user.save()), но совмещает
move с обновлением атрибутов.
Args:
conn: LDAP соединение.
user_dn: Текущий DN пользователя.
base_dn: DN целевого контейнера.
Returns:
str: Новый DN пользователя после перемещения.
Raises:
RuntimeError: Если операция перемещения не удалась.
"""
rdn = user_dn.split(",", 1)[0]
ok = conn.modify_dn(user_dn, rdn, new_superior=base_dn)
if not ok:
raise RuntimeError(f"LDAP move user to base failed: {conn.result}")
return f"{rdn},{base_dn}"
__all__ = ["UserService"]