Fix LDAP synchronization issues, improve logging, and resolve binary decoding errors

This commit is contained in:
lizerk 2026-03-19 23:50:27 +03:00
parent 06931f3706
commit 98e98289bf
6 changed files with 130 additions and 46 deletions

View File

@ -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',
}
)

View File

@ -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

View File

@ -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'`
#### 🔍 **Показать различия**
```

View File

@ -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',
}
)

View File

@ -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,

View File

@ -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: