refactor(ldap): удалены неиспользуемые файлы и методы из employees/ldap

- Удалён orm_services.py (424 строки) — не использовался нигде
- Удалён group_utils.py (142 строки) — заменён group_utils_orm.py
- Перенесена _desired_group_cns_for_employee в group_utils_orm.py
- DirectoryService: удалены ~200 строк неиспользуемых методов
  (position_*, department member helpers, conn-based group ops,
   employee_ids_to_dns, dns_to_employee_ids, etc.)
- Очищены __init__.py и utils/__init__.py от устаревших импортов
This commit is contained in:
lizerk 2026-03-18 18:53:07 +03:00
parent b5b1b20a13
commit 1ffcee9d8a
6 changed files with 40 additions and 788 deletions

View File

@ -65,30 +65,15 @@ from .domain.dtos import (
)
# Утилиты работы с группами
from .utils.group_utils import sync_user_groups_by_cns
from .utils.group_utils_orm import sync_user_groups_by_cns_orm
# ORM модели и сервисы
# ORM модели
from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit
from .orm_services import LdapOrmUserService, LdapOrmGroupService, LdapOrmDepartmentService
# Подключения
from .infrastructure.connections import _ldap
# Обратная совместимость: обёртки над SyncService
def import_departments(cfg=None):
"""Обёртка для SyncService().import_departments(cfg)."""
svc = SyncService()
return svc.import_departments(cfg or SyncConfig())
def import_users(cfg=None):
"""Обёртка для SyncService().import_users(cfg)."""
svc = SyncService()
return svc.import_users(cfg or SyncConfig())
def export_users(cfg=None):
"""Обёртка для SyncService().export_users(cfg)."""
svc = SyncService()

View File

@ -1,11 +1,10 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set
from typing import Any, Dict, List, Optional
from employees.models import LdapSyncState
from ldap3 import Connection
from ..models import Department, DepartmentRole, Employee, Position
from ..models import Department, Employee
from .domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO
# ------------------------- Сервис LDAP → DB ------------------------- #
@ -20,9 +19,6 @@ class DirectoryService:
3) Post-actions (best effort)
В случае падения БД выполняется best-effort компенсация LDAP, где разумно.
NOTE: Этот класс постепенно рефакторится. Методы работы с пользователями
делегируются к UserService (см. services/user_service.py).
"""
def __init__(self):
@ -30,16 +26,12 @@ class DirectoryService:
from .services.department_service import DepartmentService
from .services.user_service import UserService
from .services.group_service import GroupService
from .services.position_service import PositionService
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
)
self._position_service = PositionService(
group_service=self._group_service, user_service=self._user_service
)
# ====================== ВСПОМОГАТЕЛЬНЫЕ ХЕЛПЕРЫ ====================== #
@ -151,175 +143,29 @@ class DirectoryService:
"""Удаляет отдел: исключает сотрудников → удаляет группу → OU → БД."""
return self._department_service.delete_department(dept)
def add_member(self, dept: Department, employee: Employee) -> None:
"""Добавляет сотрудника в OU отдела и в одноименную группу отдела."""
return self._department_service.add_member(dept, employee)
# ======================== GROUPS ======================== #
def remove_member(self, dept: Department, employee: Employee) -> None:
"""Удаляет сотрудника из отдела: из группы → MOVE → удаление линка."""
return self._department_service.remove_member(dept, employee)
# --- Conn-based методы (используются внутри services) ---
def set_head(self, dept: Department, head: Optional[Employee]) -> Department:
"""Назначает/снимает руководителя отдела: LDAP managedBy → DB.head."""
return self._department_service.set_head(dept, head)
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)
# =========================== POSITIONS =========================== #
def reconcile_position(self, pos: Position) -> str:
"""Публичный метод — открыть соединение и привести POS к консистентности."""
return self._position_service.reconcile_position(pos)
def assign_position(self, employee: Employee, position: Position) -> None:
"""
Добавляет сотрудника в POS-группу должности и убеждается, что вложения POS в целевые группы актуальны.
"""
self._position_service.assign_position(employee, position)
def unassign_position(self, employee: Employee, position: Position) -> None:
"""
Удаляет сотрудника из POS-группы должности.
"""
self._position_service.unassign_position(employee, position)
def delete_position_group(self, position: Position) -> None:
"""
Best-effort: снять POS-группу из всех правовых групп и удалить её.
"""
self._position_service.delete_position_group(position)
# ======================== GROUPS (DRY-версия) ======================== #
def create_group(
self,
conn: Connection,
cn: str,
parent_dn: Optional[str] = None,
*,
description: Optional[str] = None,
scope: str = "global",
security_enabled: bool = True,
) -> str:
"""Создаёт группу в LDAP под указанным контейнером."""
return self._group_service.create(
conn,
cn,
parent_dn,
description=description,
scope=scope,
security_enabled=security_enabled,
)
def delete_group(self, conn: Connection, group_dn: str) -> None:
"""Удаляет группу по DN (игнорирует noSuchObject)."""
self._group_service.delete(conn, group_dn)
def rename_group(self, conn: Connection, group_dn: str, new_cn: str) -> str:
"""Переименовывает группу (modify_dn)."""
return self._group_service.rename(conn, group_dn, new_cn)
def set_group_description(
self, conn: Connection, group_dn: str, description: Optional[str]
) -> None:
"""Ставит/очищает description."""
self._group_service.set_description(conn, group_dn, description)
def sync_groups_catalog(
self, *, throttle_seconds: int = 60, delete_absent: bool = False
) -> int:
"""
Тянет группы из AD под LDAP_GROUPS_BASE и гарантирует их наличие в Django Group.
Возвращает количество созданных групп. Обновляет LdapSyncState(model='group').
Троттлит запросы к AD не чаще, чем раз в throttle_seconds.
"""
return self._group_service.sync_catalog(
throttle_seconds=throttle_seconds, delete_absent=delete_absent
)
def _modify_group_members(
self, conn: Connection, group_dn: str, op: int, members: List[str]
) -> None:
"""Применяет операцию к членству группы (DRY-хелпер)."""
self._group_service._modify_members(conn, group_dn, op, members)
def find_group_dn(
self, conn, cn: str, bases: Optional[List[str]] = None
) -> Optional[str]:
"""Ищет DN группы по CN в заданных базах."""
return self._group_service.find_dn(conn, cn, bases)
def add_group_members(
self, conn: Connection, group_dn: str, member_dns: List[str]
self, conn, group_dn: str, member_dns: List[str]
) -> None:
"""Добавляет участников (member ADD)."""
self._group_service.add_members(conn, group_dn, member_dns)
def remove_group_members(
self, conn: Connection, group_dn: str, member_dns: List[str]
self, conn, group_dn: str, member_dns: List[str]
) -> None:
"""Удаляет участников (member DELETE)."""
self._group_service.remove_members(conn, group_dn, member_dns)
def replace_group_members(
self, conn: Connection, group_dn: str, exact_member_dns: List[str]
) -> None:
"""Полная замена состава группы."""
self._group_service.replace_members(conn, group_dn, exact_member_dns)
def list_group_members(self, conn: Connection, group_dn: str) -> List[str]:
"""Возвращает DN всех участников группы."""
return self._group_service.list_members(conn, group_dn)
def find_group_dn(
self, conn: Connection, cn: str, bases: Optional[List[str]] = None
) -> Optional[str]:
"""Ищет DN группы по CN в заданных базах."""
return self._group_service.find_dn(conn, cn, bases)
# ============ LDAP HELPER METHODS (DEPARTMENT) ============ #
def _ensure_department_ou(self, conn: Connection, name: str) -> str:
"""Гарантирует наличие OU отдела + OU=Roles."""
return self._department_service._ensure_department_ou(conn, name)
def _rename_department_ou(
self, conn: Connection, dept_dn: str, new_name: str
) -> str:
"""Переименовывает OU отдела и возвращает новый DN."""
return self._department_service._rename_department_ou(conn, dept_dn, new_name)
def _set_ou_managed_by(
self, conn: Connection, dept_dn: str, head_dn: Optional[str]
) -> None:
"""Устанавливает/очищает managedBy у OU в AD (идемпотентно и с no-op при пустом состоянии).
Args:
conn (Connection): LDAP соединение.
dept_dn (str): DN OU отдела.
head_dn (Optional[str]): DN руководителя или None.
"""
return self._department_service._set_ou_managed_by(conn, dept_dn, head_dn)
def _set_ou_description(
self, conn: Connection, dept_dn: str, description: Optional[str]
) -> None:
"""Ставит/очищает description у OU."""
return self._department_service._set_ou_description(conn, dept_dn, description)
def _delete_department_ou(self, conn: Connection, dept_dn: str) -> None:
"""Удаляет OU (ignore noSuchObject)."""
return self._department_service._delete_department_ou(conn, dept_dn)
def _evict_all_users_from_department_ou(
self, conn: Connection, dept_dn: str
) -> None:
"""Перемещает всех пользователей из OU отдела в Users base."""
return self._department_service._evict_all_users_from_department_ou(
conn, dept_dn
)
def _groups_with_member(self, conn: Connection, member_dn: str) -> Set[str]:
"""Возвращает множество DN групп, где member=member_dn."""
return self._group_service.groups_with_member(conn, member_dn)
# --- Высокоуровневые методы (используются в сигналах) ---
def group_find_dn(
self, cn: str, bases: Optional[list[str]] = None
@ -341,7 +187,7 @@ class DirectoryService:
from .infrastructure.connections import _ldap
with _ldap() as conn:
return self.create_group(
return self._group_service.create(
conn,
cn,
parent_dn,
@ -354,61 +200,40 @@ class DirectoryService:
from .infrastructure.connections import _ldap
with _ldap() as conn:
self.delete_group(conn, group_dn)
self._group_service.delete(conn, group_dn)
def group_rename(self, group_dn: str, new_cn: str) -> str:
from .infrastructure.connections import _ldap
with _ldap() as conn:
return self.rename_group(conn, group_dn, new_cn)
return self._group_service.rename(conn, group_dn, new_cn)
def group_set_description(self, group_dn: str, description: Optional[str]) -> None:
from .infrastructure.connections import _ldap
with _ldap() as conn:
self.set_group_description(conn, group_dn, description)
self._group_service.set_description(conn, group_dn, description)
def group_list_members(self, group_dn: str) -> list[str]:
from .infrastructure.connections import _ldap
with _ldap() as conn:
return self.list_group_members(conn, group_dn)
return self._group_service.list_members(conn, group_dn)
def group_add_members(self, group_dn: str, member_dns: list[str]) -> None:
from .infrastructure.connections import _ldap
with _ldap() as conn:
self.add_group_members(conn, group_dn, member_dns)
self._group_service.add_members(conn, group_dn, member_dns)
def group_remove_members(self, group_dn: str, member_dns: list[str]) -> None:
from .infrastructure.connections import _ldap
with _ldap() as conn:
self.remove_group_members(conn, group_dn, member_dns)
self._group_service.remove_members(conn, group_dn, member_dns)
def group_replace_members(self, group_dn: str, exact_member_dns: list[str]) -> None:
from .infrastructure.connections import _ldap
with _ldap() as conn:
self.replace_group_members(conn, group_dn, exact_member_dns)
def employee_ids_to_dns(self, ids: list[int]) -> list[str]:
"""Конвертирует ID сотрудников в их Distinguished Names.
NOTE: Метод делегирует к UserService.
"""
return self._user_service.employee_ids_to_dns(ids)
def dns_to_employee_ids(self, dns: list[str]) -> list[int]:
"""Конвертирует Distinguished Names в ID сотрудников.
NOTE: Метод делегирует к UserService.
"""
return self._user_service.dns_to_employee_ids(dns)
def employees_brief_by_dns(self, dns: list[str]) -> list[dict]:
"""Получает краткую информацию о сотрудниках по их DN.
NOTE: Метод делегирует к UserService.
"""
return self._user_service.employees_brief_by_dns(dns)
self._group_service.replace_members(conn, group_dn, exact_member_dns)

View File

@ -1,424 +0,0 @@
"""ORM сервисы для работы с LDAP через django-ldapdb.
Упрощенная альтернатива низкоуровневым ldap3 операциям.
Использует LDAP модели (LdapUser, LdapGroup, LdapOrganizationalUnit) для CRUD.
Преимущества:
- Чистый Django ORM вместо ldap3 костылей
- Автоматическая валидация
- Унифицированный API
- Меньше кода
Использование:
- Только для WRITE операций (POST/PUT/DELETE)
- Чтение идет из обычных Django моделей (Employee, Department)
"""
import logging
from typing import Optional
from django.conf import settings
from django.utils import timezone
from employees.models import LdapSyncState
from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit
from .utils.text_utils import esc_rdn
logger = logging.getLogger(__name__)
# User Account Control values для Active Directory
UAC_ENABLED = 512
UAC_DISABLED = 514
class LdapOrmUserService:
"""Сервис для управления пользователями в LDAP через ORM."""
def create_user(
self,
*,
sam_account_name: str,
first_name: str,
last_name: str,
email: str,
employee_pk: Optional[int] = None,
phone: Optional[str] = None,
is_active: bool = True,
ou_dn: Optional[str] = None,
) -> LdapUser:
"""
Создает пользователя в LDAP используя django-ldapdb ORM.
Args:
sam_account_name: sAMAccountName (логин без домена)
first_name: Имя
last_name: Фамилия
email: Email адрес
employee_pk: ID сотрудника в Django (для employeeNumber)
phone: Телефон (опционально)
is_active: Активен ли пользователь
ou_dn: DN organizational unit для размещения
Returns:
Созданный LdapUser объект
"""
# Генерация CN и UPN
cn = f"{first_name} {last_name}".strip() or sam_account_name
cn_safe = esc_rdn(cn)
upn_suffix = getattr(settings, 'LDAP_UPN_SUFFIX', 'robotail.local')
upn = f"{sam_account_name}@{upn_suffix}"
# Определение OU для размещения
if ou_dn is None:
ou_dn = getattr(settings, 'LDAP_USERS_BASE',
getattr(settings, 'LDAP_USER_BASE',
'OU=company,DC=robotail,DC=local'))
# DN нового пользователя
dn = f"CN={cn_safe},{ou_dn}"
# Создание через ORM
user = LdapUser()
user.dn = dn
user.cn = cn
user.sam_account_name = sam_account_name
user.user_principal_name = upn
user.given_name = first_name
user.sn = last_name or "." # AD требует sn
user.display_name = cn
user.mail = email
if phone:
user.mobile = phone
if employee_pk:
user.employee_number = str(employee_pk)
# UserAccountControl
user.user_account_control = UAC_ENABLED if is_active else UAC_DISABLED
# Сохранение в LDAP
user.save()
# Создание/обновление LdapSyncState для связи Employee ↔ LDAP
if employee_pk:
# Получаем objectGUID после создания (если доступен)
guid_str = str(user.object_guid) if hasattr(user, 'object_guid') and user.object_guid else None
LdapSyncState.objects.update_or_create(
model='employee',
object_pk=str(employee_pk),
defaults={
'ldap_dn': dn,
'ldap_guid': guid_str,
'last_django_modify_ts': timezone.now(),
'last_sync_dir': 'django',
}
)
logger.info(f"Created LdapSyncState for Employee.pk={employee_pk}, DN={dn}")
logger.info(f"Created LDAP user: {dn} (sAMAccountName={sam_account_name})")
return user
def update_user(
self,
user_dn: str,
*,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
email: Optional[str] = None,
phone: Optional[str] = None,
is_active: Optional[bool] = None,
) -> LdapUser:
"""
Обновляет пользователя в LDAP через ORM.
Args:
user_dn: DN пользователя для обновления
first_name: Новое имя (если нужно изменить)
last_name: Новая фамилия
email: Новый email
phone: Новый телефон
is_active: Новый статус активности
Returns:
Обновленный LdapUser объект
"""
# Получаем пользователя по DN
try:
user = LdapUser.objects.get(dn=user_dn)
except LdapUser.DoesNotExist:
raise ValueError(f"LDAP user not found: {user_dn}")
# Обновляем поля
updated = False
if first_name is not None and user.given_name != first_name:
user.given_name = first_name
updated = True
if last_name is not None and user.sn != last_name:
user.sn = last_name or "."
updated = True
if email is not None and user.mail != email:
user.mail = email
updated = True
if phone is not None and user.mobile != phone:
user.mobile = phone
updated = True
if is_active is not None:
new_uac = UAC_ENABLED if is_active else UAC_DISABLED
if user.user_account_control != new_uac:
user.user_account_control = new_uac
updated = True
# Обновление displayName если изменились имя/фамилия
if first_name is not None or last_name is not None:
new_display = f"{user.given_name} {user.sn}".strip()
if new_display and user.display_name != new_display:
user.display_name = new_display
updated = True
if updated:
user.save()
# Обновление LdapSyncState (если есть employee_number)
if user.employee_number:
try:
sync_state = LdapSyncState.objects.get(
model='employee',
object_pk=user.employee_number
)
sync_state.last_django_modify_ts = timezone.now()
sync_state.last_sync_dir = 'django'
sync_state.save(update_fields=['last_django_modify_ts', 'last_sync_dir', 'updated_at'])
except LdapSyncState.DoesNotExist:
logger.warning(f"LdapSyncState not found for employee_pk={user.employee_number}")
logger.info(f"Updated LDAP user: {user_dn}")
return user
def delete_user(self, user_dn: str, *, soft: bool = True) -> None:
"""
Удаляет пользователя из LDAP.
Args:
user_dn: DN пользователя для удаления
soft: Если True, только деактивирует (userAccountControl=DISABLED)
Если False, полностью удаляет из LDAP
"""
try:
user = LdapUser.objects.get(dn=user_dn)
except LdapUser.DoesNotExist:
logger.warning(f"LDAP user not found for deletion: {user_dn}")
return
if soft:
# Soft delete - деактивация
user.user_account_control = UAC_DISABLED
user.save()
logger.info(f"Soft deleted (disabled) LDAP user: {user_dn}")
else:
# Hard delete - удаление из LDAP
employee_pk = user.employee_number if hasattr(user, 'employee_number') else None
user.delete()
# Удаление LdapSyncState при полном удалении
if employee_pk:
deleted_count = LdapSyncState.objects.filter(
model='employee',
object_pk=employee_pk
).delete()[0]
if deleted_count:
logger.info(f"Deleted LdapSyncState for employee_pk={employee_pk}")
logger.info(f"Deleted LDAP user: {user_dn}")
def get_user_by_dn(self, dn: str) -> Optional[LdapUser]:
"""Получает пользователя из LDAP по DN."""
try:
return LdapUser.objects.get(dn=dn)
except LdapUser.DoesNotExist:
return None
def get_user_by_employee_pk(self, employee_pk: int) -> Optional[LdapUser]:
"""
Получает пользователя из LDAP по Employee.pk.
Сначала пытается найти через LdapSyncState (более надежно),
затем через employeeNumber атрибут в LDAP.
"""
# Способ 1: через LdapSyncState (рекомендуется)
try:
sync_state = LdapSyncState.objects.get(
model='employee',
object_pk=str(employee_pk)
)
if sync_state.ldap_dn:
return self.get_user_by_dn(sync_state.ldap_dn)
except LdapSyncState.DoesNotExist:
pass
# Способ 2: через employeeNumber в LDAP (fallback)
try:
return LdapUser.objects.get(employee_number=str(employee_pk))
except LdapUser.DoesNotExist:
return None
def get_user_dn_by_employee_pk(self, employee_pk: int) -> Optional[str]:
"""
Получает DN пользователя в LDAP по Employee.pk через LdapSyncState.
Это быстрый метод без запроса к LDAP серверу.
"""
try:
sync_state = LdapSyncState.objects.get(
model='employee',
object_pk=str(employee_pk)
)
return sync_state.ldap_dn or None
except LdapSyncState.DoesNotExist:
return None
class LdapOrmGroupService:
"""Сервис для управления группами в LDAP через ORM."""
def create_group(
self,
cn: str,
description: Optional[str] = None,
ou_dn: Optional[str] = None,
) -> LdapGroup:
"""
Создает группу в LDAP.
Args:
cn: Common Name группы
description: Описание группы
ou_dn: DN organizational unit для размещения
Returns:
Созданный LdapGroup объект
"""
if ou_dn is None:
ou_dn = getattr(settings, 'LDAP_BASE_DN', 'DC=robotail,DC=local')
cn_safe = esc_rdn(cn)
dn = f"CN={cn_safe},{ou_dn}"
group = LdapGroup()
group.dn = dn
group.cn = cn
group.sam_account_name = cn
if description:
group.description = description
group.save()
logger.info(f"Created LDAP group: {dn}")
return group
def add_member(self, group_dn: str, member_dn: str) -> None:
"""Добавляет члена в группу."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
raise ValueError(f"LDAP group not found: {group_dn}")
# Добавляем DN члена в список member
if member_dn not in (group.member or []):
members = list(group.member or [])
members.append(member_dn)
group.member = members
group.save()
logger.info(f"Added {member_dn} to group {group_dn}")
def remove_member(self, group_dn: str, member_dn: str) -> None:
"""Удаляет члена из группы."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
raise ValueError(f"LDAP group not found: {group_dn}")
# Удаляем DN члена из списка member
if member_dn in (group.member or []):
members = list(group.member or [])
members.remove(member_dn)
group.member = members
group.save()
logger.info(f"Removed {member_dn} from group {group_dn}")
def delete_group(self, group_dn: str) -> None:
"""Удаляет группу из LDAP."""
try:
group = LdapGroup.objects.get(dn=group_dn)
group.delete()
logger.info(f"Deleted LDAP group: {group_dn}")
except LdapGroup.DoesNotExist:
logger.warning(f"LDAP group not found for deletion: {group_dn}")
class LdapOrmDepartmentService:
"""Сервис для управления Organizational Units (отделами) в LDAP через ORM."""
def create_ou(
self,
ou_name: str,
description: Optional[str] = None,
parent_dn: Optional[str] = None,
) -> LdapOrganizationalUnit:
"""
Создает Organizational Unit в LDAP.
Args:
ou_name: Название OU
description: Описание
parent_dn: DN родительского контейнера
Returns:
Созданный LdapOrganizationalUnit объект
"""
if parent_dn is None:
parent_dn = getattr(settings, 'LDAP_BASE_DN', 'DC=robotail,DC=local')
ou_safe = esc_rdn(ou_name)
dn = f"OU={ou_safe},{parent_dn}"
ou = LdapOrganizationalUnit()
ou.dn = dn
ou.ou = ou_name
if description:
ou.description = description
ou.save()
logger.info(f"Created LDAP OU: {dn}")
return ou
def delete_ou(self, ou_dn: str) -> None:
"""Удаляет OU из LDAP (должна быть пустой)."""
try:
ou = LdapOrganizationalUnit.objects.get(dn=ou_dn)
ou.delete()
logger.info(f"Deleted LDAP OU: {ou_dn}")
except LdapOrganizationalUnit.DoesNotExist:
logger.warning(f"LDAP OU not found for deletion: {ou_dn}")
__all__ = [
'LdapOrmUserService',
'LdapOrmGroupService',
'LdapOrmDepartmentService',
]

View File

@ -34,11 +34,8 @@ from .dn_utils import (
# Утилиты изображений
from .image_utils import normalize_avatar_to_jpeg
# Утилиты групп
from .group_utils import (
_desired_group_cns_for_employee,
sync_user_groups_by_cns,
)
# Утилиты групп (ORM)
from .group_utils_orm import sync_user_groups_by_cns_orm
__all__ = [
# Текстовые
@ -66,7 +63,6 @@ __all__ = [
"_move_to_department",
# Изображения
"normalize_avatar_to_jpeg",
# Группы
"_desired_group_cns_for_employee",
"sync_user_groups_by_cns",
# Группы (ORM)
"sync_user_groups_by_cns_orm",
]

View File

@ -1,142 +0,0 @@
from __future__ import annotations
from typing import Iterable, Optional
from django.conf import settings
from ldap3 import BASE, SUBTREE, Connection
from employees.models import Employee
from .text_utils import esc_filter
def _read_user_memberof_dns(conn: Connection, user_dn: str) -> set[str]:
"""Возвращает текущее множество DN групп (memberOf) пользователя."""
conn.search(user_dn, "(objectClass=*)", BASE, attributes=["memberOf"])
if not conn.entries:
return set()
raw = getattr(conn.entries[0], "memberOf", [])
vals = raw.values if hasattr(raw, "values") else raw
return {str(x) for x in (vals or [])}
def _group_add_member(
conn: Connection, group_dn: str, user_dn: str, *, do_write: bool
) -> None:
"""Добавляет пользователя в группу (или пропускает в dry-run)."""
from ldap3 import MODIFY_ADD
if not do_write:
return
ok = conn.modify(group_dn, {"member": [(MODIFY_ADD, [user_dn])]})
if not ok and conn.result.get("description") not in {"typeOrValueExists"}:
raise RuntimeError(f"Add member failed: {group_dn}: {conn.result}")
def _group_remove_member(
conn: Connection, group_dn: str, user_dn: str, *, do_write: bool
) -> None:
"""Удаляет пользователя из группы (или пропускает в dry-run)."""
from ldap3 import MODIFY_DELETE
if not do_write:
return
ok = conn.modify(group_dn, {"member": [(MODIFY_DELETE, [user_dn])]})
if not ok and conn.result.get("description") not in {"noSuchAttribute"}:
raise RuntimeError(f"Remove member failed: {group_dn}: {conn.result}")
def _resolve_group_dns_by_cn(
conn: Connection, cns: set[str], extra_bases: Optional[list[str]] = None
) -> dict[str, str]:
"""Ищет DN групп по их CN в OU=Groups и дополнительных базах (например, Roles отдела)."""
if not cns:
return {}
bases = [getattr(settings, "LDAP_GROUPS_BASE", "")]
if extra_bases:
bases.extend(extra_bases)
found: dict[str, str] = {}
for base in bases:
if not base:
continue
for cn in sorted(cns - set(found.keys())):
conn.search(
base,
f"(&(objectClass=group)(cn={esc_filter(cn)}))",
SUBTREE,
attributes=["distinguishedName", "cn"],
)
if conn.entries:
found[cn] = str(conn.entries[0].entry_dn)
return found
def _desired_group_cns_for_employee(emp: "Employee") -> set[str]:
"""Возвращает целевые CN групп для сотрудника из Django (Position/DeptRole/Direct)."""
cns: set[str] = set()
pos = getattr(emp, "position", None)
if pos is not None and hasattr(pos, "groups"):
cns |= {g.name for g in pos.groups.all()}
user_obj = getattr(emp, "user", None) or emp
if hasattr(user_obj, "groups"):
cns |= {g.name for g in user_obj.groups.all()}
dept_roles = getattr(emp, "department_roles", None)
if dept_roles is not None and hasattr(dept_roles, "all"):
for dr in dept_roles.all():
if hasattr(dr, "groups"):
cns |= {g.name for g in dr.groups.all()}
return cns
def sync_user_groups_by_cns(
conn: Connection,
user_dn: str,
target_cns: Iterable[str],
*,
extra_bases: list[str] | None = None,
do_write: bool = True,
) -> tuple[int, int]:
"""Приводит memberOf пользователя к точному набору CN (добавляет/удаляет).
Args:
conn: соединение LDAP.
user_dn: DN пользователя.
target_cns: итоговый набор CN групп.
extra_bases: доп. базы поиска групп (например, OU=Roles,<dept>).
do_write: False => только расчёт.
Returns:
(added, removed).
Raises:
RuntimeError: если операции LDAP завершаются ошибкой.
"""
desired_dns = set(
_resolve_group_dns_by_cn(
conn, set(target_cns), extra_bases=extra_bases
).values()
)
current_dns = _read_user_memberof_dns(conn, user_dn)
# ограничим удаление только «наших» веток (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 (depts_base and dn.endswith(depts_base) and "CN=ROLE_" in dn)
}
added = removed = 0
if do_write:
for gdn in sorted(to_add):
_group_add_member(conn, gdn, user_dn, do_write=True)
added += 1
for gdn in sorted(to_del):
_group_remove_member(conn, gdn, user_dn, do_write=True)
removed += 1
else:
added, removed = len(to_add), len(to_del)
return added, removed

View File

@ -111,9 +111,21 @@ def sync_user_groups_by_cns_orm(
return added, removed
# _desired_group_cns_for_employee — чистая Django логика, нет ldap3
# Реэкспортируем из оригинала
from .group_utils import _desired_group_cns_for_employee
def _desired_group_cns_for_employee(emp: "Employee") -> set[str]:
"""Возвращает целевые CN групп для сотрудника из Django (Position/DeptRole/Direct)."""
cns: set[str] = set()
pos = getattr(emp, "position", None)
if pos is not None and hasattr(pos, "groups"):
cns |= {g.name for g in pos.groups.all()}
user_obj = getattr(emp, "user", None) or emp
if hasattr(user_obj, "groups"):
cns |= {g.name for g in user_obj.groups.all()}
dept_roles = getattr(emp, "department_roles", None)
if dept_roles is not None and hasattr(dept_roles, "all"):
for dr in dept_roles.all():
if hasattr(dr, "groups"):
cns |= {g.name for g in dr.groups.all()}
return cns
__all__ = [