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

560 lines
20 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.

"""Сервис синхронизации данных между Django и LDAP.
Этот модуль предоставляет высокоуровневый API для двусторонней синхронизации:
- Import: LDAP -> Django (пользователи, отделы)
- Export: Django -> LDAP (обновление профилей, групп, перемещение)
Рефакторенная версия:
- Наследуется от BaseService
- Использует константы
- Логирует операции синхронизации
"""
from __future__ import annotations
import logging
from typing import Iterable, List, Optional, Set, Tuple
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from ...models import Department, Employee, LdapSyncState
from ..config import SyncConfig
from ..domain.dtos import (
LdapPersonDTO,
_entry_to_dto,
)
from ..infrastructure.connections import _ldap
from ..utils.ldap_utils import _paged_search, get_attr_str
from .base_service import BaseService
from .constants import SyncDirection
from .department_service import DepartmentService
from .group_service import GroupService
from .user_service import UserService
logger = logging.getLogger(__name__)
class SyncService(BaseService):
"""Сервис синхронизации данных между Django и LDAP.
Координирует работу UserService, DepartmentService и GroupService
для выполнения массовых операций импорта и экспорта.
"""
def __init__(
self,
user_service: Optional[UserService] = None,
department_service: Optional[DepartmentService] = None,
group_service: Optional[GroupService] = None,
):
"""Инициализация SyncService.
Args:
user_service: Сервис для работы с пользователями.
department_service: Сервис для работы с отделами.
group_service: Сервис для работы с группами.
"""
super().__init__()
self._user_service = user_service or UserService()
self._department_service = department_service or DepartmentService()
self._group_service = group_service or GroupService()
# ==================== IMPORT: DEPARTMENTS ====================
def import_departments(self, cfg: SyncConfig) -> Tuple[int, int, int]:
"""Импорт OU отделов из LDAP в Django.
Читает organizational units из LDAP и создаёт/обновляет
соответствующие записи Department в Django.
Args:
cfg: Конфигурация синхронизации.
Returns:
Tuple[int, int, int]: (created, updated, deleted).
Raises:
RuntimeError: Если не задана база отделов.
"""
raw_base = cfg.departments_base_dn or getattr(
settings, "LDAP_DEPARTMENTS_BASE", ""
)
base = raw_base.strip().strip('"').strip("'")
if not base:
raise RuntimeError("departments_base_dn не задан.")
created = updated = deleted = 0
seen_names: Set[str] = set()
with _ldap() as conn, transaction.atomic():
# Читаем все OU
entries = _paged_search(conn, base, "(objectClass=organizationalUnit)")
base_dn_lower = base.lower()
root_ou_name = (
base.split(",", 1)[0][3:].strip()
if base.upper().startswith("OU=")
else None
)
for entry in entries:
attrs = getattr(entry, "entry_attributes_as_dict", {}) or {}
dn: str = str(getattr(entry, "entry_dn", "")) or get_attr_str(
attrs, "distinguishedName"
)
dn = (dn or "").strip()
# Пропускаем корневой DN
if not dn or dn.lower() == base_dn_lower:
continue
name: str = (
get_attr_str(attrs, "ou") or get_attr_str(attrs, "name")
).strip()
if not name:
continue
# Пропускаем корневой OU
if (
root_ou_name
and name.lower() == root_ou_name.lower()
and dn.lower() != base_dn_lower
):
pass
seen_names.add(name)
# Создаём или обновляем отдел
dept, was_created = Department.objects.get_or_create(name=name)
if was_created:
created += 1
if cfg.show_changes:
logger.info(
"LDAP department created",
extra={
"event": "ldap.department.created",
"department_id": dept.pk,
},
)
# Обновляем руководителя
head_dn: str = get_attr_str(attrs, "managedBy")
head_obj = None
if head_dn:
head_pk = (
LdapSyncState.objects.filter(
model="employee", ldap_dn__iexact=head_dn
)
.values_list("object_pk", flat=True)
.first()
)
if head_pk:
head_obj = Employee.objects.filter(pk=head_pk).first()
if head_obj and getattr(dept, "head_id", None) != head_obj.id:
if not cfg.dry_run:
dept.head = head_obj
if hasattr(dept, "head_appointed_at"):
dept.head_appointed_at = timezone.now()
dept.save(update_fields=["head", "head_appointed_at"])
else:
dept.save(update_fields=["head"])
updated += 1
if cfg.show_changes:
logger.info(
"LDAP department head updated",
extra={
"event": "ldap.department_head.updated",
"department_id": dept.pk,
"head_id": head_obj.id,
"dry_run": cfg.dry_run,
},
)
# Сохраняем состояние синхронизации
state, _ = LdapSyncState.objects.get_or_create(
model="department", object_pk=str(dept.pk)
)
state.touch(
ldap_dn=dn,
last_ldap_modify_ts=None,
last_django_modify_ts=timezone.now(),
sync_dir=SyncDirection.LDAP,
)
# Удаляем отделы, которых нет в LDAP
to_delete_qs = Department.objects.exclude(name__in=seen_names)
if cfg.show_changes:
logger.info(
"LDAP departments scheduled for deletion",
extra={
"event": "ldap.department.deletion_planned",
"department_count": to_delete_qs.count(),
"dry_run": cfg.dry_run,
},
)
deleted_count = to_delete_qs.count()
if cfg.dry_run:
deleted = deleted_count
else:
# Сначала очищаем head ссылки
Department.objects.filter(
pk__in=to_delete_qs.values_list("pk", flat=True)
).update(head=None)
# Удаляем отделы
to_delete_qs.delete()
# Очищаем старые sync states
LdapSyncState.objects.filter(model="department").exclude(
object_pk__in=Department.objects.values_list("pk", flat=True)
).delete()
deleted = deleted_count
return created, updated, deleted
# ==================== IMPORT: USERS ====================
def _create_user_from_dto(self, dto: LdapPersonDTO) -> Optional[Employee]:
"""Создаёт нового Employee по данным из LDAP.
Args:
dto: Нормализованные данные из LDAP.
Returns:
Optional[Employee]: Созданный пользователь или None.
"""
if not dto.phone_e164:
logger.warning(
"LDAP user import skipped",
extra={
"event": "ldap.user_import.skipped",
"reason": "invalid_phone",
},
)
return None
user = Employee(
email=dto.email,
first_name=dto.given,
last_name=dto.sn,
is_active=dto.is_active,
is_ldap_managed=True,
)
# Контакты
user.phone_number = dto.phone_e164
if not (
getattr(user, "whatsapp", None)
or getattr(user, "telegram", None)
or getattr(user, "wechat", None)
):
user.whatsapp = dto.phone_e164
try:
user.full_clean(exclude=["password"])
except ValidationError as exc:
logger.warning(
"LDAP user import validation failed",
extra={
"event": "ldap.user_import.skipped",
"reason": "validation_error",
"validation_fields": sorted(
getattr(exc, "message_dict", {}).keys()
),
},
)
return None
user.save()
return user
def _update_user_from_dto(self, user: Employee, dto: LdapPersonDTO) -> Employee:
"""Обновляет существующего Employee данными из LDAP.
Args:
user: Существующий пользователь.
dto: Новые данные из LDAP.
Returns:
Employee: Обновлённый пользователь.
"""
changed = False
for field, val in (
("email", dto.email),
("first_name", dto.given),
("last_name", dto.sn),
("is_active", dto.is_active),
):
if val is not None and getattr(user, field) != val:
setattr(user, field, val)
changed = True
if dto.phone_e164:
if user.phone_number != dto.phone_e164:
user.phone_number = dto.phone_e164
changed = True
if not (user.whatsapp or user.telegram or user.wechat):
user.whatsapp = dto.phone_e164
changed = True
if not user.is_ldap_managed:
user.is_ldap_managed = True
changed = True
if changed:
user.save()
return user
def import_users(self, cfg: SyncConfig) -> Tuple[int, int, int]:
"""Импорт пользователей из LDAP в Django.
Читает пользователей из LDAP и создаёт/обновляет
соответствующие записи Employee в Django.
Args:
cfg: Конфигурация синхронизации.
Returns:
Tuple[int, int, int]: (created, updated, deleted).
Raises:
RuntimeError: Если не заданы базовые DN.
"""
base_users = (
cfg.users_base_dn or getattr(settings, "LDAP_USERS_BASE", "")
).strip()
base_deps = (
cfg.departments_base_dn or getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
).strip()
if not (base_users and base_deps):
raise RuntimeError("LDAP_USERS_BASE/LDAP_DEPARTMENTS_BASE не заданы.")
created = updated = deleted = 0
seen_guids: Set[str] = set()
seen_dns: Set[str] = set()
with _ldap() as conn, transaction.atomic():
# Читаем пользователей из обеих баз
flt = "(&(objectCategory=person)(objectClass=user))"
entries = _paged_search(conn, base_users, flt) + _paged_search(
conn, base_deps, flt
)
# Конвертируем в DTO
dtos: List[LdapPersonDTO] = []
for entry in entries:
dto = _entry_to_dto(entry)
if dto.dn:
seen_dns.add(dto.dn)
if dto.guid:
seen_guids.add(dto.guid)
dtos.append(dto)
# Индексируем существующих пользователей
from ..repositories.employee_repository import (
_bind_user_department,
_cleanup_absent_users,
_find_user_for_dto,
_load_existing_users_index,
)
from ..repositories.sync_state_repository import _touch_sync_state
by_guid, by_email = _load_existing_users_index(dtos)
# Разделяем на create/update
to_create: List[LdapPersonDTO] = []
to_update: List[Tuple[Employee, LdapPersonDTO]] = []
for dto in dtos:
existing = _find_user_for_dto(dto, by_guid=by_guid, by_email=by_email)
if existing:
to_update.append((existing, dto))
else:
to_create.append(dto)
skipped = 0
processed: List[Tuple[Employee, LdapPersonDTO]] = []
# UPDATE
for user, dto in to_update:
self._update_user_from_dto(user, dto)
# ORM: записываем Django PK в LDAP employeeNumber если пусто
if dto.dn and not cfg.dry_run:
try:
from ..orm_models import LdapUser
ldap_user = LdapUser.objects.get(dn=dto.dn)
if not ldap_user.employee_number:
ldap_user.employee_number = str(user.pk)
ldap_user.save()
except Exception as error:
logger.debug(
"LDAP employee number update failed",
extra={
"event": "ldap.employee_number.failed",
"employee_id": user.pk,
"error_type": type(error).__name__,
},
)
processed.append((user, dto))
updated += 1
# CREATE
for dto in to_create:
user = self._create_user_from_dto(dto)
if not user:
skipped += 1
continue
# ORM: записываем Django PK в LDAP employeeNumber
if dto.dn and not cfg.dry_run:
try:
from ..orm_models import LdapUser
ldap_user = LdapUser.objects.get(dn=dto.dn)
ldap_user.employee_number = str(user.pk)
ldap_user.save()
except Exception as error:
logger.debug(
"LDAP employee number update failed",
extra={
"event": "ldap.employee_number.failed",
"employee_id": user.pk,
"error_type": type(error).__name__,
},
)
processed.append((user, dto))
created += 1
if cfg.show_changes:
logger.info(
"LDAP user import changes prepared",
extra={
"event": "ldap.user_import.changes",
"created_count": created,
"updated_count": updated,
"skipped_count": skipped,
"dry_run": cfg.dry_run,
},
)
if skipped:
logger.warning(
"LDAP records skipped during import",
extra={
"event": "ldap.user_import.skipped_summary",
"skipped_count": skipped,
},
)
# Привязываем к отделам и сохраняем sync state
for user, dto in processed:
_bind_user_department(user, dto.dn)
_touch_sync_state(
user,
dn=dto.dn,
guid=dto.guid,
last_ldap_modify_ts=dto.when_changed,
sync_dir=SyncDirection.LDAP,
dry_run=cfg.dry_run,
)
# Очищаем отсутствующих
deleted = _cleanup_absent_users(
seen_guids=seen_guids,
seen_dns=seen_dns,
dry_run=cfg.dry_run,
show_changes=cfg.show_changes,
)
return created, updated, deleted
# ==================== EXPORT: USERS ====================
def export_users(self, cfg: SyncConfig) -> Tuple[int, int, int, int, int]:
"""Экспорт пользователей из Django в LDAP.
Создаёт/обновляет пользователей в LDAP на основе данных из Django.
Включает: логины/UPN, перемещение между отделами, аватары, группы.
Args:
cfg: Конфигурация синхронизации.
Returns:
Tuple[int, int, int, int, int]:
(logins_set, moved, avatars_set, groups_added, groups_removed).
"""
# TODO: Реализовать через UserService
logger.warning(
"LDAP user export is not implemented",
extra={"event": "ldap.user_export.not_implemented"},
)
return 0, 0, 0, 0, 0
def export_users_delete(
self, cfg: SyncConfig, employees: Iterable[Employee]
) -> int:
"""Удаляет учётные записи пользователей в LDAP.
Args:
cfg: Конфигурация синхронизации.
employees: Итератор пользователей для удаления.
Returns:
int: Количество успешно удалённых DN.
"""
do_write = not cfg.dry_run
deleted = 0
with _ldap():
for emp in employees:
state_dn = (
LdapSyncState.objects.filter(
model="employee", object_pk=str(emp.pk)
)
.values_list("ldap_dn", flat=True)
.first()
)
user_dn = (state_dn or "").strip()
if not user_dn:
logger.warning(
"LDAP user deletion skipped",
extra={
"event": "ldap.user_delete.skipped",
"employee_id": emp.pk,
"reason": "missing_ldap_dn",
},
)
continue
try:
if do_write:
self._user_service.delete_user(emp)
deleted += 1
except Exception as exc:
logger.exception(
"LDAP user deletion failed",
extra={
"event": "ldap.user_delete.failed",
"employee_id": emp.pk,
"error_type": type(exc).__name__,
},
)
return deleted