diff --git a/backend/employees/admin.py b/backend/employees/admin.py index 18d4b73c..a525b9be 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -538,9 +538,12 @@ class EmployeeAdmin(DjangoUserAdmin): Django считается источником истины - данные из БД будут записаны в Active Directory (создание или обновление). """ + import logging from employees.ldap.services import UserService from employees.ldap.orm_models import LdapUser + logger = logging.getLogger(__name__) + success_count = 0 created_count = 0 updated_count = 0 @@ -549,46 +552,70 @@ class EmployeeAdmin(DjangoUserAdmin): service = UserService() for employee in queryset: + logger.info(f"[sync_from_django_to_ldap] Начинаем обработку employee ID={employee.pk}, email={employee.email}") try: # Ищем существующего LDAP пользователя ldap_user = None + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: is_ldap_managed={employee.is_ldap_managed}") + if employee.is_ldap_managed: # Пробуем найти по employee_number + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: поиск в LDAP по employee_number={employee.pk}") ldap_user = LdapUser.objects.filter( employee_number=str(employee.pk) ).first() + if ldap_user: + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: найден LDAP user с DN={ldap_user.dn}") + else: + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: не найден по employee_number") if ldap_user: # ОБНОВЛЕНИЕ существующего LDAP пользователя + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: LDAP пользователь существует, начинаем проверку изменений") try: changes = {} + + logger.debug(f"[sync_from_django_to_ldap] Employee {employee.pk}: сравнение given_name: LDAP='{ldap_user.given_name}' vs Django='{employee.first_name}'") if ldap_user.given_name != employee.first_name: changes['first_name'] = employee.first_name + + logger.debug(f"[sync_from_django_to_ldap] Employee {employee.pk}: сравнение sn: LDAP='{ldap_user.sn}' vs Django='{employee.last_name}'") if ldap_user.sn != employee.last_name: changes['last_name'] = employee.last_name + + logger.debug(f"[sync_from_django_to_ldap] Employee {employee.pk}: сравнение mail: LDAP='{ldap_user.mail}' vs Django='{employee.email}'") if ldap_user.mail != employee.email: changes['email'] = employee.email ldap_phone = ldap_user.telephone_number or ldap_user.mobile + logger.debug(f"[sync_from_django_to_ldap] Employee {employee.pk}: сравнение phone: LDAP='{ldap_phone}' vs Django='{employee.phone_number}'") if employee.phone_number and ldap_phone != employee.phone_number: changes['phone_number'] = employee.phone_number + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: обнаружено изменений: {len(changes)} -> {list(changes.keys())}") + if changes: + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: вызов service.update_user с изменениями: {changes}") service.update_user(employee, changes) + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: успешно обновлен в LDAP") updated_count += 1 # Обновляем sync state + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: обновление LdapSyncState") LdapSyncState.objects.update_or_create( model='employee', object_pk=str(employee.pk), defaults={ 'ldap_dn': ldap_user.dn, - 'last_sync_dir': 'django_to_ldap', + 'last_sync_dir': 'django', } ) + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: LdapSyncState обновлен") else: + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: изменений не обнаружено, уже синхронизирован") success_count += 1 # Уже синхронизирован except Exception as e: + logger.exception(f"[sync_from_django_to_ldap] Employee {employee.pk}: ошибка обновления в LDAP") error_count += 1 self.message_user( request, @@ -597,20 +624,42 @@ class EmployeeAdmin(DjangoUserAdmin): ) else: # СОЗДАНИЕ нового LDAP пользователя + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: LDAP пользователь не найден, создаем новый") try: # Генерируем временный пароль import secrets + from employees.ldap.domain.dtos import DirectoryUserDTO + from employees.ldap.utils.phone_utils import normalize_phone + temp_password = secrets.token_urlsafe(16) + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: временный пароль сгенерирован") + + phone_e164 = None + if employee.phone_number: + phone_e164 = normalize_phone(employee.phone_number) + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: телефон нормализован: {phone_e164}") + + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: создание DirectoryUserDTO") + dto = DirectoryUserDTO( + first_name=employee.first_name, + last_name=employee.last_name, + email=employee.email, + phone_e164=phone_e164, + department_dn=None, + group_cns=[], + initial_password=temp_password, + ) + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: DTO создан: {dto}") # Создаем через сервис - ldap_user = service.create_user( - employee=employee, - password=temp_password, - ) + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: вызов service.create_user(dto)") + service.create_user(dto) + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: LDAP пользователь успешно создан") created_count += 1 employee.is_ldap_managed = True employee.save(update_fields=['is_ldap_managed']) + logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: is_ldap_managed установлен в True") self.message_user( request, @@ -619,6 +668,7 @@ class EmployeeAdmin(DjangoUserAdmin): level=messages.SUCCESS ) except Exception as e: + logger.exception(f"[sync_from_django_to_ldap] Employee {employee.pk}: ошибка создания в LDAP") error_count += 1 self.message_user( request, @@ -626,6 +676,7 @@ class EmployeeAdmin(DjangoUserAdmin): level=messages.ERROR ) except Exception as e: + logger.exception(f"[sync_from_django_to_ldap] Employee {employee.pk}: необработанное исключение на верхнем уровне") error_count += 1 self.message_user( request, @@ -728,7 +779,7 @@ class EmployeeAdmin(DjangoUserAdmin): defaults={ 'ldap_dn': ldap_user.dn, 'ldap_guid': str(ldap_user.object_guid) if hasattr(ldap_user, 'object_guid') else None, - 'last_sync_dir': 'ldap_to_django', + 'last_sync_dir': 'ldap', } ) diff --git a/backend/employees/apps.py b/backend/employees/apps.py index 5ca3bc96..f54cdb6f 100644 --- a/backend/employees/apps.py +++ b/backend/employees/apps.py @@ -15,12 +15,11 @@ class EmployeesConfig(AppConfig): def _patch_ldapdb_compiler(self): """Патчит django-ldapdb для поддержки Value выражений. - Проблема: ldapdb компилятор на строке 246 вызывает e[0].field.attname, + Проблема: ldapdb компилятор в results_iter вызывает e[0].field.attname, но у полей из Value() выражений (например, от .exists()) нет attname. - Это приводит к: AttributeError: 'IntegerField' object has no attribute 'attname' - - Решение: Патчим results_iter чтобы безопасно проверять наличие attname. + Решение: Патчим results_iter, заменяя прямые обращения к .attname + на безопасный getattr(..., 'attname', None). """ try: from ldapdb.backends.ldap.compiler import SQLCompiler @@ -29,35 +28,66 @@ class EmployeesConfig(AppConfig): original_results_iter = SQLCompiler.results_iter - def patched_results_iter(self, *args, **kwargs): - """Исправленная версия results_iter с поддержкой Value выражений.""" - # Вызываем оригинальный генератор, но оборачиваем в try-catch - for result in original_results_iter(self, *args, **kwargs): - yield result - - # Заменяем весь метод для правильной обработки - import ldap - from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE - - def safe_results_iter(self, results=None, do_slice=True): - """Безопасная версия results_iter с проверкой attname.""" - if results is None: - results = self.execute_sql( - result_type='multi', chunked_fetch=False, - chunk_size=GET_ITERATOR_CHUNK_SIZE, + def safe_results_iter(self, results=None, tuple_expected=False, + chunked_fetch=False, chunk_size=None): + """Обёртка над оригинальным results_iter с безопасным attname.""" + try: + yield from original_results_iter( + self, results=results, tuple_expected=tuple_expected, + chunked_fetch=chunked_fetch, + **({"chunk_size": chunk_size} if chunk_size is not None else {}), ) + except AttributeError as exc: + if "attname" not in str(exc): + raise + # Fallback: повторяем логику оригинала с безопасным getattr + yield from self._safe_results_iter_fallback() + + def _safe_results_iter_fallback(self): + """Fallback-итератор с безопасными проверками attname.""" + import ldap as ldap_lib - if results is None: + lookup = None + try: + from ldapdb.backends.ldap.compiler import query_as_ldap + lookup = query_as_ldap(self.query, compiler=self, connection=self.connection) + except Exception: return - for dn, attrs in results: + if lookup is None: + return + + if len(self.query.select): + fields = [x.field for x in self.query.select] + else: + fields = self.query.model._meta.fields + + attrlist = [x.db_column for x in fields if x.db_column] + + try: + vals = self.connection.search_s( + base=lookup.base, + scope=lookup.scope, + filterstr=lookup.filterstr, + attrlist=attrlist, + ) + except ldap_lib.NO_SUCH_OBJECT: + return + + # Слайсинг + pos = 0 + for dn, attrs in vals: + if (self.query.low_mark and pos < self.query.low_mark) or \ + (self.query.high_mark is not None and pos >= self.query.high_mark): + pos += 1 + continue + row = [] self.setup_query() for e in self.select: if isinstance(e[0], aggregates.Count): value = 0 input_field = e[0].get_source_expressions()[0].field - # Безопасная проверка attname if getattr(input_field, 'attname', None) == 'dn': value = 1 elif hasattr(input_field, 'from_ldap'): @@ -70,7 +100,6 @@ class EmployeesConfig(AppConfig): value = len(result) row.append(value) else: - # Безопасная проверка attname с getattr if getattr(e[0].field, 'attname', None) == 'dn': row.append(dn) elif hasattr(e[0].field, 'from_ldap'): @@ -79,10 +108,11 @@ class EmployeesConfig(AppConfig): connection=self.connection)) else: row.append(None) - yield row + pos += 1 SQLCompiler.results_iter = safe_results_iter + SQLCompiler._safe_results_iter_fallback = _safe_results_iter_fallback except ImportError: # ldapdb не установлен - это нормально для тестов без LDAP diff --git a/backend/employees/ldap/ADMIN_GUIDE.md b/backend/employees/ldap/ADMIN_GUIDE.md index 8cdfaaf8..7b439d50 100644 --- a/backend/employees/ldap/ADMIN_GUIDE.md +++ b/backend/employees/ldap/ADMIN_GUIDE.md @@ -32,7 +32,7 @@ - Копирует данные из Active Directory в Django Employee - Используется когда LDAP содержит актуальные данные - Обновляет: first_name, last_name, email, phone_number -- Устанавливает `last_sync_dir = 'ldap_to_django'` +- Устанавливает `last_sync_dir = 'ldap'` #### 🔄 **Django → LDAP** (Django как источник истины) ``` @@ -41,7 +41,7 @@ - Копирует данные из Django Employee в Active Directory - Используется когда БД содержит актуальные данные - Вызывает `UserService.update_user()` (с полной бизнес-логикой) -- Устанавливает `last_sync_dir = 'django_to_ldap'` +- Устанавливает `last_sync_dir = 'django'` #### 🔍 **Показать различия** ``` diff --git a/backend/employees/ldap/admin.py b/backend/employees/ldap/admin.py index 51704365..02362e0a 100644 --- a/backend/employees/ldap/admin.py +++ b/backend/employees/ldap/admin.py @@ -212,11 +212,11 @@ class LdapUserAdmin(admin.ModelAdmin): object_pk=obj.employee_number ) - if sync_state.last_sync_dir == 'django_to_ldap': + if sync_state.last_sync_dir == 'django': icon = '⬆️' color = '#0066cc' text = 'Django → LDAP' - elif sync_state.last_sync_dir == 'ldap_to_django': + elif sync_state.last_sync_dir == 'ldap': icon = '⬇️' color = '#00cc66' text = 'LDAP → Django' @@ -306,8 +306,6 @@ class LdapUserAdmin(admin.ModelAdmin): LDAP считается источником истины - данные из Active Directory перезапишут данные в Django Employee. """ - from employees.ldap.services import UserService - success_count = 0 error_count = 0 @@ -332,7 +330,7 @@ class LdapUserAdmin(admin.ModelAdmin): object_pk=str(emp.pk), defaults={ 'ldap_dn': ldap_user.dn, - 'last_sync_dir': 'ldap_to_django', + 'last_sync_dir': 'ldap', } ) diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index 63eff10e..000a6794 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -516,10 +516,15 @@ class UserService(BaseService): self._password_service.set_password(conn, dn, new_password) def _enable_user(self, conn: Connection, dn: str) -> None: - """Включает учётку (UAC=ENABLED).""" - ldap_user = LdapUser.objects.get(dn=dn) - ldap_user.user_account_control = UserAccountControl.ENABLED - ldap_user.save() + """Включает учётку (UAC=ENABLED) через низкоуровневую модификацию.""" + from ldap3 import MODIFY_REPLACE + conn.modify(dn, { + 'userAccountControl': [(MODIFY_REPLACE, [str(UserAccountControl.ENABLED)])] + }) + if not conn.result['result'] == 0: + raise DirectoryLdapError( + f"Failed to enable user {dn}: {conn.result['description']}" + ) def _unique_logins( self, diff --git a/backend/employees/models.py b/backend/employees/models.py index 01fd59cf..936762da 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -604,7 +604,7 @@ class LdapSyncState(models.Model): ldap_guid (str | None): objectGUID из Active Directory. last_ldap_modify_ts (datetime | None): Последний modifyTimestamp/whenChanged из LDAP. last_django_modify_ts (datetime | None): Последний updated_at из Django на момент фиксации. - last_sync_dir (str): 'ldap'|'django'|'auto' — направление последнего успешного синка. + last_sync_dir (str): 'ldap'|'django'|'auto'|'manual' — направление последнего успешного синка. data_hash (str | None): Хэш значимых полей (опционально) для ускоренной детекции изменений. updated_at (datetime): Время обновления записи состояния. @@ -648,14 +648,14 @@ class LdapSyncState(models.Model): ldap_guid (str | None): Новый GUID. last_ldap_modify_ts: Новый штамп LDAP (UTC). last_django_modify_ts: Новый штамп Django (UTC). - sync_dir (str | None): Направление ('ldap'|'django'|'auto'). + sync_dir (str | None): Направление ('ldap'|'django'|'auto'|'manual'). data_hash (str | None): Хэш полезных данных. Raises: ValueError: Если sync_dir не из допустимого набора. """ - if sync_dir and sync_dir not in ("ldap", "django", "auto"): - raise ValueError("sync_dir должен быть 'ldap'|'django'|'auto'") + if sync_dir and sync_dir not in ("ldap", "django", "auto", "manual"): + raise ValueError("sync_dir должен быть 'ldap'|'django'|'auto'|'manual'") if ldap_dn is not None: self.ldap_dn = ldap_dn if ldap_guid is not None: