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:
parent
8cdfbfb6ef
commit
95ff0dc0af
@ -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"])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 пользователя.
|
||||
|
||||
@ -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
|
||||
126
backend/employees/ldap/utils/group_utils_orm.py
Normal file
126
backend/employees/ldap/utils/group_utils_orm.py
Normal 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',
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
# Итоги
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user