From b2ecbdda3f6170b0a79e8ba3fe679f570cd08694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 7 Jan 2026 15:55:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(ldap):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=20sync=5Femployee=5Fids=5Fto=5Fldap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Команда синхронизирует Django pk существующих пользователей в LDAP employeeNumber: - По умолчанию dry-run режим - Опция --user-id для теста на одном пользователе - Опция --force для перезаписи существующих значений - Использует LdapSyncState для поиска DN пользователей --- .../commands/sync_employee_ids_to_ldap.py | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 backend/employees/management/commands/sync_employee_ids_to_ldap.py diff --git a/backend/employees/management/commands/sync_employee_ids_to_ldap.py b/backend/employees/management/commands/sync_employee_ids_to_ldap.py new file mode 100644 index 00000000..ecb5f5e7 --- /dev/null +++ b/backend/employees/management/commands/sync_employee_ids_to_ldap.py @@ -0,0 +1,200 @@ +"""Management command для синхронизации Django pk в LDAP employeeNumber. + +Записывает id существующих в БД пользователей в атрибут employeeNumber +соответствующих записей Active Directory. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +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 + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Записывает Django pk пользователей в LDAP атрибут employeeNumber.""" + + help = ( + "Синхронизирует Django pk сотрудников в LDAP employeeNumber. " + "По умолчанию dry-run, для применения используйте --no-dry-run." + ) + + def add_arguments(self, parser) -> None: + parser.add_argument( + "--dry-run", + action="store_true", + default=True, + help="Сухой прогон без изменений (по умолчанию).", + ) + parser.add_argument( + "--no-dry-run", + action="store_false", + dest="dry_run", + help="Применить изменения в LDAP.", + ) + parser.add_argument( + "--force", + action="store_true", + default=False, + help="Перезаписать employeeNumber даже если уже заполнен.", + ) + parser.add_argument( + "--user-id", + type=int, + default=None, + help="Синхронизировать только конкретного пользователя по id.", + ) + + def handle(self, *args, **options) -> None: + dry_run: bool = options["dry_run"] + force: bool = options["force"] + user_id: Optional[int] = options["user_id"] + verbose: int = options.get("verbosity", 1) + + employee_id_attr = getattr( + settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber" + ) + + if dry_run: + self.stdout.write( + self.style.WARNING("=== DRY-RUN режим (изменения НЕ применяются) ===") + ) + else: + self.stdout.write( + self.style.SUCCESS("=== ПРИМЕНЕНИЕ изменений в LDAP ===") + ) + + # Собираем пользователей с DN в LdapSyncState + sync_states = LdapSyncState.objects.filter( + model="employee", + ldap_dn__isnull=False, + ).exclude(ldap_dn="") + + if user_id: + sync_states = sync_states.filter(object_pk=user_id) + + if not sync_states.exists(): + self.stdout.write(self.style.WARNING("Нет пользователей с LDAP DN.")) + return + + self.stdout.write(f"Найдено записей LdapSyncState: {sync_states.count()}") + + updated = 0 + skipped_already_set = 0 + skipped_no_employee = 0 + errors = 0 + + with _ldap() as conn: + 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 не найден в БД" + ) + ) + skipped_no_employee += 1 + continue + + # Читаем текущее значение employeeNumber + try: + current_attrs = read_attrs(conn, dn, [employee_id_attr]) + current_value = current_attrs.get(employee_id_attr) + 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 + + # Записываем + 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: + updated += 1 + + # Итоги + self.stdout.write("") + self.stdout.write("=" * 50) + + if dry_run: + self.stdout.write( + self.style.SUCCESS(f"Будет обновлено: {updated}") + ) + else: + self.stdout.write( + self.style.SUCCESS(f"Обновлено: {updated}") + ) + + self.stdout.write(f"Пропущено (уже установлено): {skipped_already_set}") + self.stdout.write(f"Пропущено (нет Employee): {skipped_no_employee}") + + if errors: + self.stdout.write( + self.style.ERROR(f"Ошибок: {errors}") + ) + + if dry_run and updated > 0: + self.stdout.write("") + self.stdout.write( + self.style.WARNING( + "Для применения изменений запустите с --no-dry-run" + ) + )