refactor(ldap): заменить ldap3 CRUD на django-ldapdb ORM

- user_service.py: read_attrs/modify_user_attrs → LdapUser ORM,
  sync_user_groups_by_cns → ORM, delete_user без _ldap() контекста
- group_service.py: _wrapped методы через LdapGroup ORM
- department_service.py: _set_ou_description/_delete_department_ou → ORM
- views.py, employees.py: ldap_info через LdapUser ORM
- sync_employee_ids_to_ldap: полная замена на LdapUser ORM
- __init__.py: ORM реэкспорты, обёртки import_*/export_*
- orm_models.py: compatibility shim для django-ldapdb + Django 5.x
- Создан group_utils_orm.py (ORM замена group_utils)
- Удалён deprecated sync_service.py (352 строки)

TODO(ldap-orm) помечены: _set_password, _enable_user, _unique_logins,
_create_user_in_ldap, _move_user_to_base, rename (modify_dn),
sync_catalog (paged search), _set_ou_managed_by
This commit is contained in:
lizerk 2026-03-18 14:25:53 +03:00
parent 8cdfbfb6ef
commit 95ff0dc0af
11 changed files with 461 additions and 565 deletions

View File

@ -2282,7 +2282,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
- При отсутствии кэша - запрашивается из LDAP и сохраняется
- force_refresh=true сбрасывает кэш и обновляет из LDAP
"""
from employees.ldap.repositories.ldap_repository import LdapRepository
from employees.ldap.orm_models import LdapUser
emp = self.get_object()
force_refresh = request.query_params.get('force_refresh', '').lower() == 'true'
@ -2314,23 +2314,18 @@ class EmployeeViewSet(viewsets.ModelViewSet):
status=status.HTTP_404_NOT_FOUND,
)
# Запрашиваем данные из LDAP
# Запрашиваем данные из LDAP через django-ldapdb ORM
try:
conn = _conn()
ldap_repo = LdapRepository(conn)
attrs = ldap_repo.read_attrs(
ldap_sync.ldap_dn,
["sAMAccountName"],
)
ldap_user = LdapUser.objects.get(dn=ldap_sync.ldap_dn)
if not attrs or not attrs.get("sAMAccountName"):
sam_account_name = ldap_user.sam_account_name
if not sam_account_name:
return Response(
{"detail": "Не удалось получить LDAP информацию"},
status=status.HTTP_404_NOT_FOUND,
)
# Кэшируем результат в username
sam_account_name = attrs.get("sAMAccountName")
emp.username = sam_account_name
emp.save(update_fields=["username"])

View File

@ -235,18 +235,13 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
# Записываем Django PK в LDAP employeeNumber для обратной связки
try:
from employees.ldap.infrastructure.connections import _ldap
from employees.ldap.repositories.ldap_repository import \
modify_user_attrs
from employees.ldap.orm_models import LdapUser
ldap_user = LdapUser.objects.get(dn=dn)
employee_id_attr = getattr(
settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber")
with _ldap() as conn:
modify_user_attrs(
conn, dn,
{employee_id_attr: str(instance.pk)},
do_write=True
)
setattr(ldap_user, 'employee_number', str(instance.pk))
ldap_user.save()
except Exception as e:
logger.warning(f"Failed to set employeeNumber in LDAP: {e}")
@ -532,7 +527,7 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
@action(detail=True, methods=["get"], url_path="ldap-info")
def ldap_info(self, request, pk=None):
"""GET /api/v1/employees/{id}/ldap-info/ — LDAP информация о сотруднике."""
from employees.ldap.repositories.ldap_repository import LdapRepository
from employees.ldap.orm_models import LdapUser
emp = self.get_object()
force_refresh = request.query_params.get(
@ -564,20 +559,15 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
)
try:
conn = _conn()
ldap_repo = LdapRepository(conn)
attrs = ldap_repo.read_attrs(
ldap_sync.ldap_dn,
["sAMAccountName"],
)
ldap_user = LdapUser.objects.get(dn=ldap_sync.ldap_dn)
if not attrs or not attrs.get("sAMAccountName"):
sam_account_name = ldap_user.sam_account_name
if not sam_account_name:
return Response(
{"detail": "Не удалось получить LDAP информацию"},
status=status.HTTP_404_NOT_FOUND,
)
sam_account_name = attrs.get("sAMAccountName")
emp.username = sam_account_name
emp.save(update_fields=["username"])
@ -589,6 +579,11 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
except LdapUser.DoesNotExist:
return Response(
{"detail": "Не удалось получить LDAP информацию"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
logger.error(
f"Error fetching LDAP info for employee {emp.id}: {e}", exc_info=True

View File

@ -44,11 +44,7 @@
# Текущие импорты (для обратной совместимости)
from .directory_service import DirectoryService
from .sync_service import (
import_departments,
import_users,
export_users,
)
from .services.sync_service import SyncService
# Конфигурация
from .config import SyncConfig, SyncMode, SyncScope, DISABLED_FLAG
@ -70,10 +66,34 @@ 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 модели и сервисы
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()
return svc.export_users(cfg or SyncConfig())
__all__ = [
# Сервисы
"DirectoryService",
@ -100,6 +120,15 @@ __all__ = [
# Утилиты
"sync_user_groups_by_cns",
"sync_user_groups_by_cns_orm",
# ORM
"LdapUser",
"LdapGroup",
"LdapOrganizationalUnit",
"LdapOrmUserService",
"LdapOrmGroupService",
"LdapOrmDepartmentService",
# Подключения
"_ldap",

View File

@ -10,7 +10,19 @@
- Меньше кода, больше удобства
"""
import datetime as _dt
from django.conf import settings
from django.utils import timezone as _dj_tz
# Compatibility: django-ldapdb 1.5.1 uses timezone.utc removed in Django 5.x
if not hasattr(_dj_tz, 'utc'):
class _UtcCompat:
"""pytz-compatible UTC stub for django-ldapdb."""
def localize(self, dt):
return dt.replace(tzinfo=_dt.timezone.utc)
_dj_tz.utc = _UtcCompat()
from ldapdb.models import Model as LdapModel
from ldapdb.models.fields import (
CharField,

View File

@ -20,11 +20,10 @@ from ..errors import (
DirectoryLdapError,
DirectoryServiceError,
)
from ..utils.group_utils import sync_user_groups_by_cns
from ..orm_models import LdapOrganizationalUnit
from ..infrastructure.connections import _ldap
from ..repositories.ldap_repository import (
ensure_container_exists,
ldap_modify_or_ignore,
)
from ..utils.dn_utils import _move_to_department
from ..utils.ldap_utils import group_type
@ -1043,6 +1042,9 @@ class DepartmentService:
) -> str:
"""Переименовывает OU отдела и возвращает новый DN.
TODO(ldap-orm): modify_dn — ldap3 навсегда.
django-ldapdb не поддерживает переименование.
Args:
conn: LDAP соединение.
dept_dn: Текущий DN OU.
@ -1066,6 +1068,9 @@ class DepartmentService:
) -> None:
"""Устанавливает/очищает managedBy у OU в AD (идемпотентно).
TODO(ldap-orm): Можно заменить на ORM (ou.managed_by = head_dn; save()),
но нужна проверка существования head_dn и обработка ADD/REPLACE/DELETE.
Args:
conn: LDAP соединение.
dept_dn: DN OU отдела.
@ -1129,35 +1134,32 @@ class DepartmentService:
def _set_ou_description(
self, conn: Connection, dept_dn: str, description: Optional[str]
) -> None:
"""Ставит/очищает description у OU.
"""Ставит/очищает description у OU (ORM).
Args:
conn: LDAP соединение.
conn: LDAP соединение (не используется, ORM).
dept_dn: DN OU отдела.
description: Описание или None для удаления.
"""
changes = (
{"description": [(MODIFY_REPLACE, [description])]}
if description
else {"description": [(MODIFY_DELETE, [])]}
)
ldap_modify_or_ignore(conn, dept_dn, changes, {"noSuchAttribute"})
try:
ou = LdapOrganizationalUnit.objects.get(dn=dept_dn)
ou.description = description or ""
ou.save()
except LdapOrganizationalUnit.DoesNotExist:
pass
def _delete_department_ou(self, conn: Connection, dept_dn: str) -> None:
"""Удаляет OU (ignore noSuchObject).
"""Удаляет OU (ORM, ignore DoesNotExist).
Args:
conn: LDAP соединение.
conn: LDAP соединение (не используется, ORM).
dept_dn: DN OU для удаления.
Raises:
RuntimeError: Если удаление не удалось (кроме noSuchObject).
"""
ok = conn.delete(dept_dn)
if not ok and (conn.result or {}).get("description") not in {
"noSuchObject"
}:
raise RuntimeError(f"LDAP delete OU failed: {conn.result}")
try:
ou = LdapOrganizationalUnit.objects.get(dn=dept_dn)
ou.delete()
except LdapOrganizationalUnit.DoesNotExist:
pass
def _evict_all_users_from_department_ou(
self, conn: Connection, dept_dn: str

View File

@ -17,6 +17,7 @@ from employees.models import LdapSyncState
from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, SUBTREE, Connection
from ..infrastructure.connections import _ldap
from ..orm_models import LdapGroup
from ..repositories.ldap_repository import ldap_modify_or_ignore
from ..utils.text_utils import esc_filter, esc_rdn
from ..utils.ldap_utils import get_guid_str, group_type
@ -85,6 +86,9 @@ class GroupService:
) -> str:
"""Создаёт группу в LDAP под указанным контейнером.
TODO(ldap-orm): conn.add() + groupType — ldap3 навсегда.
LdapGroup не имеет поля groupType, нужно добавить в модель.
Args:
conn: Подключение LDAP.
cn: Имя группы (CN).
@ -126,7 +130,11 @@ class GroupService:
raise RuntimeError(f"LDAP delete group failed: {conn.result}")
def rename(self, conn: Connection, group_dn: str, new_cn: str) -> str:
"""Переименовывает группу (modify_dn)."""
"""Переименовывает группу (modify_dn).
TODO(ldap-orm): modify_dn — ldap3 навсегда.
django-ldapdb не поддерживает переименование.
"""
new_rdn = f"CN={esc_rdn(new_cn)}"
ok = conn.modify_dn(group_dn, new_rdn)
if not ok:
@ -261,6 +269,9 @@ class GroupService:
) -> int:
"""
Тянет группы из AD под LDAP_GROUPS_BASE и гарантирует их наличие в Django Group.
TODO(ldap-orm): paged search — ldap3 навсегда.
Массовый импорт с throttling и кешированием.
Возвращает количество созданных групп. Обновляет LdapSyncState(model='group').
Троттлит запросы к AD не чаще, чем раз в throttle_seconds.
"""
@ -336,9 +347,12 @@ class GroupService:
def find_dn_wrapped(
self, cn: str, bases: Optional[list[str]] = None
) -> Optional[str]:
"""Ищет DN группы (открывает соединение автоматически)."""
with _ldap() as conn:
return self.find_dn(conn, cn, bases=bases)
"""Ищет DN группы по CN (ORM)."""
try:
group = LdapGroup.objects.get(cn=cn)
return group.dn
except LdapGroup.DoesNotExist:
return None
def create_wrapped(
self,
@ -349,7 +363,10 @@ class GroupService:
scope: str = "global",
security_enabled: bool = True,
) -> str:
"""Создает группу (открывает соединение автоматически)."""
"""Создает группу (ldap3 — нужен groupType).
TODO(ldap-orm): conn.add() + groupType остаётся ldap3.
"""
with _ldap() as conn:
return self.create(
conn,
@ -361,40 +378,79 @@ class GroupService:
)
def delete_wrapped(self, group_dn: str) -> None:
"""Удаляет группу (открывает соединение автоматически)."""
with _ldap() as conn:
self.delete(conn, group_dn)
"""Удаляет группу (ORM)."""
try:
group = LdapGroup.objects.get(dn=group_dn)
group.delete()
except LdapGroup.DoesNotExist:
pass # Уже удалена
def rename_wrapped(self, group_dn: str, new_cn: str) -> str:
"""Переименовывает группу (открывает соединение автоматически)."""
"""Переименовывает группу (ldap3 — modify_dn).
TODO(ldap-orm): modify_dn — ldap3 навсегда.
"""
with _ldap() as conn:
return self.rename(conn, group_dn, new_cn)
def set_description_wrapped(
self, group_dn: str, description: Optional[str]
) -> None:
"""Устанавливает описание группы (открывает соединение автоматически)."""
with _ldap() as conn:
self.set_description(conn, group_dn, description)
"""Устанавливает описание группы (ORM)."""
try:
group = LdapGroup.objects.get(dn=group_dn)
group.description = description or ""
group.save()
except LdapGroup.DoesNotExist:
pass
def list_members_wrapped(self, group_dn: str) -> list[str]:
"""Список участников группы (открывает соединение автоматически)."""
with _ldap() as conn:
return self.list_members(conn, group_dn)
"""Список участников группы (ORM)."""
try:
group = LdapGroup.objects.get(dn=group_dn)
return list(group.member or [])
except LdapGroup.DoesNotExist:
return []
def add_members_wrapped(self, group_dn: str, member_dns: list[str]) -> None:
"""Добавляет участников (открывает соединение автоматически)."""
with _ldap() as conn:
self.add_members(conn, group_dn, member_dns)
"""Добавляет участников (ORM)."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
return
members = list(group.member or [])
changed = False
for dn in member_dns:
if dn not in members:
members.append(dn)
changed = True
if changed:
group.member = members
group.save()
def remove_members_wrapped(self, group_dn: str, member_dns: list[str]) -> None:
"""Удаляет участников (открывает соединение автоматически)."""
with _ldap() as conn:
self.remove_members(conn, group_dn, member_dns)
"""Удаляет участников (ORM)."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
return
members = list(group.member or [])
changed = False
for dn in member_dns:
if dn in members:
members.remove(dn)
changed = True
if changed:
group.member = members
group.save()
def replace_members_wrapped(
self, group_dn: str, exact_member_dns: list[str]
) -> None:
"""Заменяет участников (открывает соединение автоматически)."""
with _ldap() as conn:
self.replace_members(conn, group_dn, exact_member_dns)
"""Заменяет участников (ORM)."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
return
group.member = exact_member_dns
group.save()

View File

@ -20,20 +20,18 @@ from ..errors import (
DirectoryLdapError,
DirectoryServiceError,
)
from ..utils.group_utils import sync_user_groups_by_cns
from ..orm_models import LdapUser
from ..utils.group_utils_orm import sync_user_groups_by_cns_orm
from ..infrastructure.connections import _ldap
from ..repositories.ldap_repository import (
ensure_container_exists,
is_taken,
ldap_modify_or_ignore,
modify_user_attrs,
read_attrs,
)
from ..utils.image_utils import normalize_avatar_to_jpeg
from ..utils.ldap_utils import (
build_logins_for_user,
cn_candidates,
get_guid_str,
)
from ..utils.text_utils import esc_rdn
@ -107,8 +105,9 @@ class UserService:
emp.set_unusable_password()
emp.save(update_fields=["password"])
attrs = read_attrs(conn, dn, ["objectGUID"])
guid = get_guid_str(attrs)
# ORM: чтение objectGUID + запись employeeNumber
ldap_user = LdapUser.objects.get(dn=dn)
guid = str(ldap_user.object_guid) if ldap_user.object_guid else None
self._touch_state(
model="employee",
@ -119,13 +118,9 @@ class UserService:
last_django_modify_ts=timezone.now(),
)
# Записываем Django PK в LDAP employeeNumber для связки
employee_id_attr = getattr(
settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber"
)
modify_user_attrs(
conn, dn, {employee_id_attr: str(emp.pk)}, do_write=True
)
# ORM: записываем Django PK в LDAP employeeNumber для связки
ldap_user.employee_number = str(emp.pk)
ldap_user.save()
# Временно импортируем для совместимости
# TODO: Убрать после полного рефакторинга
@ -147,16 +142,15 @@ class UserService:
# 3) Post-actions (best effort)
try:
if dto.group_cns:
sync_user_groups_by_cns(conn, dn, set(dto.group_cns), do_write=True)
sync_user_groups_by_cns_orm(dn, set(dto.group_cns))
if dto.avatar_bytes:
# Увеличен размер до 384px для максимального качества в LDAP
avatar = normalize_avatar_to_jpeg(
dto.avatar_bytes, size_px=384, max_kb=100
)
if avatar:
modify_user_attrs(
conn, dn, {"thumbnailPhoto": avatar}, do_write=True
)
ldap_user_av = LdapUser.objects.get(dn=dn)
ldap_user_av.thumbnail_photo = avatar
ldap_user_av.save()
except Exception:
pass
@ -359,28 +353,36 @@ class UserService:
except DirectoryServiceError:
dn = None
with _ldap() as conn:
if dn:
try:
modify_user_attrs(conn, dn, {"userAccountControl": 0x0202})
except Exception as e:
raise DirectoryLdapError(f"LDAP soft-disable failed: {e}") from e
# ORM: soft-disable (UAC = disabled + password expired)
if dn:
try:
with transaction.atomic():
emp_pk = emp.pk
emp.delete()
LdapSyncState.objects.filter(
model="employee", object_pk=str(emp_pk)
).delete()
ldap_user = LdapUser.objects.get(dn=dn)
ldap_user.user_account_control = 0x0202
ldap_user.save()
except LdapUser.DoesNotExist:
pass # Уже удалён из LDAP
except Exception as e:
raise DirectoryDbError(str(e)) from e
raise DirectoryLdapError(f"LDAP soft-disable failed: {e}") from e
if dn:
try:
self._hard_delete_user_in_ldap(conn, dn)
except Exception as e:
raise DirectoryLdapError(f"LDAP hard delete 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()
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()
except LdapUser.DoesNotExist:
pass # Уже удалён из LDAP
except Exception as e:
raise DirectoryLdapError(f"LDAP hard delete failed: {e}") from e
# ==================== DN ↔ ID Conversion Methods ==================== #
@ -460,7 +462,12 @@ class UserService:
return state
def _set_password(self, conn: Connection, dn: str, new_password: str) -> None:
"""Меняет пароль пользователя в AD (control)."""
"""Меняет пароль пользователя в AD (control).
TODO(ldap-orm): Невозможно заменить на ORM.
AD modify_password — extended operation, django-ldapdb не поддерживает.
Остаётся через ldap3 навсегда.
"""
ok = conn.extend.microsoft.modify_password(dn, new_password)
if not ok:
msg = conn.result or {}
@ -470,7 +477,11 @@ class UserService:
raise RuntimeError(f"LDAP set password failed: {msg}")
def _enable_user(self, conn: Connection, dn: str) -> None:
"""Включает учётку (UAC=512)."""
"""Включает учётку (UAC=512).
TODO(ldap-orm): Можно заменить на ORM (ldap_user.user_account_control = 512; save()).
Но используется в связке с _create_user_in_ldap → conn уже в контексте.
"""
ldap_modify_or_ignore(
conn, dn, {"userAccountControl": [(MODIFY_REPLACE, [UAC_ENABLED])]}, set()
)
@ -478,7 +489,12 @@ class UserService:
def _unique_logins(
self, conn: Connection, dto: DirectoryUserDTO, upn_suffix: str
) -> tuple[str, str]:
"""Генерирует уникальные sAMAccountName и UPN."""
"""Генерирует уникальные sAMAccountName и UPN.
TODO(ldap-orm): is_taken() использует ldap3 raw search с OR фильтром.
Можно заменить на LdapUser.objects.filter(sam_account_name=s).exists(),
но нужен фильтр по нескольким атрибутам одновременно.
"""
sam, upn = build_logins_for_user(
first_name=dto.first_name,
last_name=dto.last_name,
@ -544,7 +560,11 @@ class UserService:
return None
def _create_user_in_ldap(self, conn: Connection, dto: DirectoryUserDTO) -> str:
"""Создаёт объект user в LDAP (AD) и возвращает его DN."""
"""Создаёт объект user в LDAP (AD) и возвращает его DN.
TODO(ldap-orm): conn.add(), ensure_container_exists, cn_candidates — ldap3 навсегда.
django-ldapdb не поддерживает создание с перебором CN и проверкой контейнера.
"""
base_dn = (
dto.department_dn
or getattr(settings, "LDAP_USERS_BASE", None)
@ -621,43 +641,49 @@ class UserService:
if move_to_department_dn:
dn = svc._move_to_department(conn, dn, move_to_department_dn)
# 3-5) ORM: UAC + Атрибуты + Аватар (batch через один save)
ldap_user = LdapUser.objects.get(dn=dn)
orm_dirty = False
# 3) UAC
if is_active_val is not None:
uac_val = UAC_ENABLED if is_active_val else UAC_DISABLED
modify_user_attrs(conn, dn, {"userAccountControl": uac_val}, do_write=True)
ldap_user.user_account_control = uac_val
orm_dirty = True
# 4) Прочие атрибуты
ldap_attrs_map = {
"first_name": "givenName",
phone_write = self._phone_write_attr()
orm_field_map = {
"first_name": "given_name",
"last_name": "sn",
"email": "mail",
"phone_number": self._phone_write_attr(),
"display_name": "displayName",
"phone_number": "mobile" if phone_write == "mobile" else "telephone_number",
"display_name": "display_name",
}
attrs_to_update = {}
for model_key, ldap_attr in ldap_attrs_map.items():
for model_key, orm_field in orm_field_map.items():
if model_key in model_changes:
val = model_changes[model_key]
if val or ldap_attr == "sn":
attrs_to_update[ldap_attr] = val if val else "."
if attrs_to_update:
modify_user_attrs(conn, dn, attrs_to_update, do_write=True)
if val or orm_field == "sn":
setattr(ldap_user, orm_field, val if val else ".")
orm_dirty = True
# 5) Аватар
avatar_saved = False
if avatar_bytes:
# Увеличен размер до 384px для максимального качества в LDAP
avatar = normalize_avatar_to_jpeg(
avatar_bytes, size_px=384, max_kb=100
)
if avatar:
modify_user_attrs(conn, dn, {"thumbnailPhoto": avatar}, do_write=True)
avatar_saved = True # Флаг для сохранения в БД
ldap_user.thumbnail_photo = avatar
orm_dirty = True
avatar_saved = True
# 6) Группы
if orm_dirty:
ldap_user.save()
# 6) Группы (ORM)
if group_cns is not None:
sync_user_groups_by_cns(conn, dn, set(group_cns), do_write=True)
sync_user_groups_by_cns_orm(dn, set(group_cns))
# Возвращаем dn и флаг сохранения аватара
return (dn, avatar_bytes if avatar_saved else None)
@ -695,6 +721,10 @@ class UserService:
def _move_user_to_base(self, conn: Connection, user_dn: str, base_dn: str) -> str:
"""Перемещает пользовательский объект в указанный контейнер.
TODO(ldap-orm): Невозможно заменить на ORM.
modify_dn (move/rename) — не поддерживается django-ldapdb.
Остаётся через ldap3 навсегда.
Args:
conn: LDAP соединение.
user_dn: Текущий DN пользователя.

View File

@ -1,352 +0,0 @@
"""DEPRECATED: Этот модуль сохранён для обратной совместимости.
Используйте вместо него:
from employees.ldap.services.sync_service import SyncService
Функции в этом модуле являются обёртками над новым SyncService.
"""
from __future__ import annotations
import logging
from typing import Iterable, Tuple
from django.conf import settings
from ldap3 import BASE
from ..models import Employee, EmployeeDepartment, LdapSyncState
from .config import SyncConfig
from .infrastructure.connections import _ldap
from .utils.dn_utils import (
_ensure_user_dn,
_move_to_department,
_target_department_ou_dn,
extract_department_from_dn,
)
from .utils.group_utils import (
_desired_group_cns_for_employee,
sync_user_groups_by_cns,
)
from .repositories.ldap_repository import (
is_taken,
read_attrs,
modify_user_attrs,
)
from .utils.ldap_utils import (
build_logins_for_user,
)
from .utils.image_utils import normalize_avatar_to_jpeg
from .services.sync_service import SyncService
logger = logging.getLogger(__name__)
# Создаём глобальный экземпляр для backward compatibility
_sync_service = SyncService()
# ==================== WRAPPER FUNCTIONS ====================
def import_departments(*, cfg: SyncConfig) -> Tuple[int, int, int]:
"""DEPRECATED: Используйте SyncService().import_departments().
Импорт OU отделов из LDAP в Django.
Args:
cfg: Конфигурация синхронизации.
Returns:
Tuple[int, int, int]: (created, updated, deleted).
"""
return _sync_service.import_departments(cfg)
def import_users(*, cfg: SyncConfig) -> Tuple[int, int, int]:
"""DEPRECATED: Используйте SyncService().import_users().
Импорт пользователей из LDAP в Django.
Args:
cfg: Конфигурация синхронизации.
Returns:
Tuple[int, int, int]: (created, updated, deleted).
"""
return _sync_service.import_users(cfg)
def export_users(*, cfg: SyncConfig) -> Tuple[int, int, int, int, int]:
"""DEPRECATED: Используйте SyncService().export_users().
Полный экспорт пользователей из Django в LDAP.
Args:
cfg: Конфигурация синхронизации.
Returns:
Tuple[int, int, int, int, int]:
(logins_set, moved, avatars_set, groups_added, groups_removed).
"""
return _export_users_legacy(cfg=cfg)
def export_users_delete(
*, cfg: SyncConfig, employees: Iterable[Employee]
) -> int:
"""DEPRECATED: Используйте SyncService().export_users_delete().
Удаляет учётные записи пользователей в LDAP.
Args:
cfg: Конфигурация синхронизации.
employees: Итератор пользователей для удаления.
Returns:
int: Количество успешно удалённых DN.
"""
return _sync_service.export_users_delete(cfg, employees)
# ==================== LEGACY IMPLEMENTATIONS ====================
# Эти функции сохранены для сложной логики export_users,
# которая ещё не полностью перенесена в новые сервисы
def _employees_with_dn_qs():
"""Возвращает QuerySet сотрудников с LDAP DN."""
emp_ids = (
LdapSyncState.objects.filter(model="employee")
.exclude(ldap_dn="")
.values_list("object_pk", flat=True)
)
return Employee.objects.filter(pk__in=list(emp_ids)).select_related()
def _ensure_and_persist_user_dn(conn, emp, *, do_write):
"""Обеспечивает наличие DN и сохраняет в LdapSyncState."""
user_dn = _ensure_user_dn(conn, emp)
if do_write:
st, _ = LdapSyncState.objects.get_or_create(
model="employee", object_pk=str(emp.pk)
)
if (st.ldap_dn or "") != user_dn:
st.touch(ldap_dn=user_dn, sync_dir="ldap")
return user_dn
def _active_dept_name(emp: Employee) -> str | None:
"""Имя активного отдела сотрудника."""
link = (
EmployeeDepartment.objects.filter(employee=emp, is_active=True)
.select_related("department")
.first()
)
return link.department.name if link and link.department_id else None
def export_users_create_attrs(*, cfg: SyncConfig) -> int:
"""Создаёт недостающие sAMAccountName/UPN и базовые ФИО-атрибуты."""
upn_suffix: str = getattr(settings, "LDAP_UPN_SUFFIX", "robotail.local")
do_write = not cfg.dry_run
assigned = 0
with _ldap() as conn:
for emp in _employees_with_dn_qs():
try:
user_dn = _ensure_and_persist_user_dn(
conn, emp, do_write=do_write
)
except RuntimeError as e:
logger.warning("[WARN] %s", e)
continue
cur = read_attrs(
conn, user_dn, ["sAMAccountName", "userPrincipalName"]
)
if cur["sAMAccountName"] and cur["userPrincipalName"]:
continue
guid = (
LdapSyncState.objects.filter(
model="employee", object_pk=str(emp.pk)
)
.values_list("ldap_guid", flat=True)
.first()
)
sam, upn = build_logins_for_user(
first_name=emp.first_name or "",
last_name=emp.last_name or "",
email=emp.email or "",
upn_suffix=upn_suffix,
is_taken_sam=lambda s: is_taken(
conn, attributes={"sAMAccountName": s}
),
is_taken_upn=lambda u: is_taken(
conn, attributes={"userPrincipalName": u}
),
guid=guid,
)
modify_user_attrs(
conn,
user_dn,
{
"sAMAccountName": sam,
"userPrincipalName": upn,
"givenName": emp.first_name or None,
"sn": emp.last_name or None,
"displayName": (
f"{emp.first_name} {emp.last_name}".strip() or None
),
},
do_write=do_write,
)
assigned += 1
return assigned
def export_users_update_profile(
*, cfg: SyncConfig
) -> Tuple[int, int]:
"""Обновляет профильные данные: перемещение между отделами и аватары."""
do_write = not cfg.dry_run
moved = avatars_set = 0
with _ldap() as conn:
for emp in _employees_with_dn_qs():
try:
user_dn = _ensure_and_persist_user_dn(
conn, emp, do_write=do_write
)
except RuntimeError as e:
logger.warning("[WARN] %s", e)
continue
# MOVE между отделами
current_dept = extract_department_from_dn(user_dn)
target_dept = _active_dept_name(emp)
if target_dept and current_dept != target_dept:
if do_write:
_move_to_department(conn, user_dn, target_dept)
else:
target_ou = _target_department_ou_dn(target_dept)
if not (
conn.search(
target_ou,
"(objectClass=organizationalUnit)",
BASE,
attributes=["ou"],
)
and conn.entries
):
logger.warning(
"[WARN] Целевой OU не найден: %s", target_ou
)
continue
moved += 1
# avatar -> thumbnailPhoto
avatar_bytes: bytes | None = None
avatar_field = getattr(emp, "avatar", None)
if avatar_field and hasattr(avatar_field, "read"):
avatar_bytes = avatar_field.read()
if avatar_bytes:
try:
# Увеличен размер до 384px для максимального качества в LDAP
avatar_bytes = normalize_avatar_to_jpeg(
avatar_bytes, size_px=384, max_kb=100
)
except Exception as exc:
logger.warning(
"[WARN] avatar: не удалось нормализовать для %s: %s",
user_dn,
exc,
)
else:
modify_user_attrs(
conn,
user_dn,
{"thumbnailPhoto": avatar_bytes},
do_write=do_write,
)
avatars_set += 1
return moved, avatars_set
def export_users_sync_groups(*, cfg: SyncConfig) -> Tuple[int, int]:
"""Приводит членства пользователей в LDAP-группах к целевому набору."""
do_write = not cfg.dry_run
groups_added = groups_removed = 0
with _ldap() as conn:
for emp in _employees_with_dn_qs():
try:
user_dn = _ensure_and_persist_user_dn(
conn, emp, do_write=do_write
)
except RuntimeError as e:
logger.warning("[WARN] %s", e)
continue
current_dept = extract_department_from_dn(user_dn)
target_dept = _active_dept_name(emp)
desired_cns = _desired_group_cns_for_employee(emp)
extra_bases: list[str] = []
dept_for_roles = target_dept or current_dept
if dept_for_roles:
dept_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
# NOTE: Группы ролей теперь лежат прямо в OU отдела (не в OU=Roles)
extra_bases.append(f"OU={dept_for_roles},{dept_base}")
added, removed = sync_user_groups_by_cns(
conn,
user_dn,
desired_cns,
extra_bases=extra_bases,
do_write=do_write,
)
groups_added += added
groups_removed += removed
return groups_added, groups_removed
def _export_users_legacy(*, cfg: SyncConfig) -> Tuple[int, int, int, int, int]:
"""Legacy реализация export_users через старые функции."""
from django.utils import timezone
logins_set = export_users_create_attrs(cfg=cfg)
moved, avatars_set = export_users_update_profile(cfg=cfg)
groups_added, groups_removed = export_users_sync_groups(cfg=cfg)
do_write = not cfg.dry_run
with _ldap() as conn:
from django.db import transaction
with transaction.atomic():
now = timezone.now()
for emp in _employees_with_dn_qs().only("pk"):
try:
user_dn = _ensure_and_persist_user_dn(
conn, emp, do_write=do_write
)
except RuntimeError as e:
logger.warning("[WARN] state.touch пропущен: %s", e)
continue
state, _ = LdapSyncState.objects.get_or_create(
model="employee", object_pk=str(emp.pk)
)
state.touch(
ldap_dn=user_dn,
last_django_modify_ts=now,
sync_dir="django",
)
return logins_set, moved, avatars_set, groups_added, groups_removed

View File

@ -0,0 +1,126 @@
"""ORM-версии утилит для работы с группами LDAP через django-ldapdb.
Замена низкоуровневых ldap3 операций для управления memberOf.
"""
from __future__ import annotations
import logging
from typing import Iterable, Optional, Set
from django.conf import settings
from employees.models import Employee
from ..orm_models import LdapUser, LdapGroup
from .text_utils import esc_filter
logger = logging.getLogger(__name__)
def read_user_memberof_dns(user_dn: str) -> set[str]:
"""Возвращает множество DN групп (memberOf) пользователя через ORM."""
try:
user = LdapUser.objects.get(dn=user_dn)
return set(user.member_of or [])
except LdapUser.DoesNotExist:
return set()
def group_add_member_orm(group_dn: str, member_dn: str) -> None:
"""Добавляет пользователя в группу через ORM."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
raise ValueError(f"LDAP group not found: {group_dn}")
members = list(group.member or [])
if member_dn not in members:
members.append(member_dn)
group.member = members
group.save()
def group_remove_member_orm(group_dn: str, member_dn: str) -> None:
"""Удаляет пользователя из группы через ORM."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
raise ValueError(f"LDAP group not found: {group_dn}")
members = list(group.member or [])
if member_dn in members:
members.remove(member_dn)
group.member = members
group.save()
def resolve_group_dns_by_cn_orm(cns: set[str]) -> dict[str, str]:
"""Ищет DN групп по их CN через ORM."""
if not cns:
return {}
found: dict[str, str] = {}
for cn in sorted(cns):
try:
group = LdapGroup.objects.get(cn=cn)
found[cn] = group.dn
except LdapGroup.DoesNotExist:
continue
return found
def sync_user_groups_by_cns_orm(
user_dn: str,
target_cns: Iterable[str],
*,
do_write: bool = True,
) -> tuple[int, int]:
"""Приводит memberOf пользователя к набору CN через ORM.
Args:
user_dn: DN пользователя.
target_cns: итоговый набор CN групп.
do_write: False => только расчёт.
Returns:
(added, removed).
"""
desired_dns = set(resolve_group_dns_by_cn_orm(set(target_cns)).values())
current_dns = read_user_memberof_dns(user_dn)
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_orm(gdn, user_dn)
added += 1
for gdn in sorted(to_del):
group_remove_member_orm(gdn, user_dn)
removed += 1
else:
added, removed = len(to_add), len(to_del)
return added, removed
# _desired_group_cns_for_employee — чистая Django логика, нет ldap3
# Реэкспортируем из оригинала
from .group_utils import _desired_group_cns_for_employee
__all__ = [
'read_user_memberof_dns',
'group_add_member_orm',
'group_remove_member_orm',
'resolve_group_dns_by_cn_orm',
'sync_user_groups_by_cns_orm',
'_desired_group_cns_for_employee',
]

View File

@ -4,12 +4,8 @@ from typing import Literal
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from employees.ldap.sync_service import (
SyncConfig,
export_users,
import_departments,
import_users,
)
from employees.ldap.config import SyncConfig
from employees.ldap.services.sync_service import SyncService
class Command(BaseCommand):
@ -77,13 +73,14 @@ class Command(BaseCommand):
if mode == "ldap":
total_changes = 0
svc = SyncService()
# Отделы
if scope in ("all", "departments"):
self.stdout.write(
self.style.NOTICE("[LDAP] Импорт отделов и managedBy...")
)
created_d, updated_d, deleted_d = import_departments(cfg=cfg)
created_d, updated_d, deleted_d = svc.import_departments(cfg)
total_changes += created_d + updated_d + deleted_d
deleted_label = "к удалению" if cfg.dry_run else "удалено"
self.stdout.write(
@ -95,7 +92,7 @@ class Command(BaseCommand):
# Пользователи
if scope in ("all", "users"):
self.stdout.write(self.style.NOTICE("[LDAP] Импорт пользователей..."))
created_u, updated_u, deleted_u = import_users(cfg=cfg)
created_u, updated_u, deleted_u = svc.import_users(cfg)
total_changes += created_u + updated_u + deleted_u
deleted_label = "к удалению" if cfg.dry_run else "удалено"
self.stdout.write(
@ -115,8 +112,9 @@ class Command(BaseCommand):
"[DJANGO] Write-back пользователей (логины, MOVE, avatar, группы)..."
)
)
logins_set, moved, avatars_set, groups_added, groups_removed = export_users(
cfg=cfg
svc = SyncService()
logins_set, moved, avatars_set, groups_added, groups_removed = svc.export_users(
cfg
)
self.stdout.write(
self.style.SUCCESS(

View File

@ -13,8 +13,7 @@ from django.conf import settings
from django.core.management.base import BaseCommand
from employees.models import Employee, LdapSyncState
from employees.ldap.infrastructure.connections import _ldap
from employees.ldap.repositories.ldap_repository import modify_user_attrs, read_attrs
from employees.ldap.orm_models import LdapUser
logger = logging.getLogger(__name__)
@ -92,82 +91,88 @@ class Command(BaseCommand):
skipped_no_employee = 0
errors = 0
with _ldap() as conn:
for state in sync_states:
dn = state.ldap_dn
pk = state.object_pk
for state in sync_states:
dn = state.ldap_dn
pk = state.object_pk
# Проверяем что Employee существует
try:
employee = Employee.objects.get(pk=pk)
except Employee.DoesNotExist:
if verbose:
self.stdout.write(
self.style.WARNING(
f" [SKIP] pk={pk}: Employee не найден в БД"
)
# Проверяем что Employee существует
try:
employee = Employee.objects.get(pk=pk)
except Employee.DoesNotExist:
if verbose:
self.stdout.write(
self.style.WARNING(
f" [SKIP] pk={pk}: Employee не найден в БД"
)
skipped_no_employee += 1
continue
)
skipped_no_employee += 1
continue
# Читаем текущее значение employeeNumber
# Читаем текущее значение employeeNumber через ORM
try:
ldap_user = LdapUser.objects.get(dn=dn)
current_value = ldap_user.employee_number or None
except LdapUser.DoesNotExist:
self.stdout.write(
self.style.ERROR(
f" [ERROR] pk={pk}, DN={dn}: не найден в LDAP"
)
)
errors += 1
continue
except Exception as e:
self.stdout.write(
self.style.ERROR(
f" [ERROR] pk={pk}, DN={dn}: не удалось прочитать — {e}"
)
)
errors += 1
continue
# Проверяем нужно ли обновлять
expected_value = str(pk)
if current_value == expected_value:
if verbose > 1:
self.stdout.write(
f" [OK] pk={pk}: уже установлено {employee_id_attr}={current_value}"
)
skipped_already_set += 1
continue
if current_value and not force:
if verbose:
self.stdout.write(
self.style.WARNING(
f" [SKIP] pk={pk}: {employee_id_attr}={current_value} "
f"(отличается от pk, используйте --force)"
)
)
skipped_already_set += 1
continue
# Записываем через ORM
action = "UPDATE" if current_value else "SET"
if verbose or dry_run:
email = employee.email or "(no email)"
self.stdout.write(
f" [{action}] pk={pk}, email={email}, DN={dn}: "
f"{employee_id_attr}={current_value!r} -> {expected_value!r}"
)
if not dry_run:
try:
current_attrs = read_attrs(conn, dn, [employee_id_attr])
current_value = current_attrs.get(employee_id_attr)
ldap_user.employee_number = expected_value
ldap_user.save()
updated += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(
f" [ERROR] pk={pk}, DN={dn}: не удалось прочитать — {e}"
f" [ERROR] pk={pk}: не удалось записать — {e}"
)
)
errors += 1
continue
# Проверяем нужно ли обновлять
expected_value = str(pk)
if current_value == expected_value:
if verbose > 1:
self.stdout.write(
f" [OK] pk={pk}: уже установлено {employee_id_attr}={current_value}"
)
skipped_already_set += 1
continue
if current_value and not force:
if verbose:
self.stdout.write(
self.style.WARNING(
f" [SKIP] pk={pk}: {employee_id_attr}={current_value} "
f"(отличается от pk, используйте --force)"
)
)
skipped_already_set += 1
continue
# Записываем
action = "UPDATE" if current_value else "SET"
if verbose or dry_run:
email = employee.email or "(no email)"
self.stdout.write(
f" [{action}] pk={pk}, email={email}, DN={dn}: "
f"{employee_id_attr}={current_value!r} -> {expected_value!r}"
)
if not dry_run:
try:
modify_user_attrs(
conn, dn, {employee_id_attr: expected_value}, do_write=True
)
updated += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(
f" [ERROR] pk={pk}: не удалось записать — {e}"
)
)
errors += 1
else:
else:
updated += 1
# Итоги