lizerk 75662ec7ea feat(ldap): Полная миграция на django-ldapdb ORM с миксинами и Django Admin
LDAP ORM модели (orm_models.py):
================================
✅ Применены миксины ModifyDnMixin и LdapSyncStateMixin ко всем моделям
✅ LdapUser - поддержка modify_dn + автоуправление LdapSyncState
✅ LdapGroup - поддержка modify_dn для перемещения групп
✅ LdapOrganizationalUnit - поддержка modify_dn для реорганизации

Миксины (mixins.py) - НОВЫЙ ФАЙЛ:
=================================
✅ ModifyDnMixin - автоматический modify_dn при изменении base_dn
   • Отслеживает изменения base_dn
   • Выполняет LDAP modify_dn с newsuperior
   • Обновляет _saved_dn для корректного последующего save()
   • Использование: user.base_dn = new_dn; user.save()

✅ LdapSyncStateMixin - автоуправление LdapSyncState
   • Автоматически создает/обновляет LdapSyncState при save()
   • Автоматически удаляет LdapSyncState при delete()
   • Настройки: _sync_model_name, _sync_pk_field

Django Admin для LDAP (admin.py) - НОВЫЙ ФАЙЛ:
==============================================
✅ LdapUserAdmin - полноценная админка для LDAP пользователей
   • List display: CN, SAM, email, Django Employee link, sync status
   • Search: по CN, SAM, email, имени, фамилии
   • Filters: по userAccountControl
   • Actions синхронизации:
     - sync_from_ldap_to_django (LDAP → Django)
     - sync_from_django_to_ldap (Django → LDAP)
     - show_sync_diff (сравнение данных)
   • Fieldsets: идентификация, персональные данные, контакты, UAC, группы
   • DN форматирование с подсветкой компонентов
   • Sync status indicators с иконками

Django Admin для Employee (employees/admin.py):
===============================================
✅ Добавлены зеркальные actions синхронизации:
   • sync_from_django_to_ldap - создание/обновление в LDAP
     - Автоматическое создание LDAP пользователя если не существует
     - Генерация временного пароля
     - Установка is_ldap_managed = True
   • sync_from_ldap_to_django - обновление из LDAP
   • show_sync_diff - сравнение Django ↔ LDAP
✅ Импорт django.contrib.messages для уведомлений

Сервисы LDAP (services/):
=========================
✅ UserService - полная миграция на ORM для UPDATE операций
✅ GroupService - использование ORM для модификаций
✅ DepartmentService - ORM для обновления OU
✅ PositionService - ORM для групп позиций

Тесты (tests/unit/):
===================
✅ test_user_service.py - обновлены под ORM
✅ test_group_service.py - обновлены под ORM
✅ test_position_service.py - обновлены под ORM
✅ conftest.py - добавлены фикстуры для ORM моделей

Authentication (auth_backends.py):
=================================
✅ EmailOrPhoneBackend - добавлено подробное debug логирование
   • Логирование email/phone lookup
   • Логирование is_active проверки
   • Логирование password check с hash prefix
✅ SuperuserOnlyBackend - добавлено debug логирование
   • Логирование parent auth результата
   • Логирование is_superuser проверки

API (api/auth/serializers.py):
==============================
✅ PhoneOrEmailTokenObtainPairSerializer - исправлен KeyError
   • Добавлена проверка credentials перед super().validate()
   • Явный AuthenticationFailed при отсутствии учетных данных

Middleware (middleware.py):
===========================
✅ Очистка от устаревшего кода (305 → 102 строки)
   • ❌ Удалена EmailVerificationMiddleware (не используется)
   • ❌ Удалена RegistrationIPRestrictionMiddleware (не используется)
   • ❌ Удалены все ссылки на namespace 'auth_front'
   • ✅ Возврат 403 для неавторизованных вместо редиректа

Settings (settings.py):
=======================
✅ LOGIN_URL изменен с 'auth_front:login' на '/admin/login/'
✅ Добавлено DEBUG логирование для:
   • django.contrib.auth
   • eusrr_backend.auth_backends

Миграции:
=========
✅ Удалены старые миграции LDAP моделей (0042, 0043)
✅ Добавлена миграция LdapSyncQueue (0042_ldapsyncqueue.py)

Документация:
=============
✅ ADMIN_GUIDE.md - руководство по использованию LDAP Admin
✅ docs/guides/ldap-orm-mixins-usage.md - usage guide для миксинов
✅ docs/completed/ldap-orm-create-support.md - документация ORM миграции
✅ docs/in-progress/LDAP_ORM_MIGRATION_PLAN.md - план миграции

Инфраструктура (__init__.py):
=============================
✅ Добавлен импорт admin для авторегистрации
✅ Экспорт ORM моделей (LdapUser, LdapGroup, LdapOrganizationalUnit)

ИТОГО:
======
• Полная миграция с ldap3 на django-ldapdb ORM для UPDATE/MOVE/DELETE
• Унификация: один подход для всех LDAP операций через модели
• Автоматизация: синхронизация состояния и перемещения через миксины
• UI: полноценный Django Admin для управления LDAP объектами
• Отладка: расширенное логирование аутентификации
• Чистота: удален устаревший код middleware и миграций
• Тесты: 29 passed, 0 failed, 18 skipped

Breaking Changes: НЕТ - обратная совместимость сохранена
2026-03-19 22:33:06 +03:00

477 lines
18 KiB
Python
Raw 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 Admin для LDAP ORM моделей.
Предоставляет интерфейс для:
- Просмотра LDAP-данных
- Ручного редактирования полей (с осторожностью)
- Синхронизации при рассинхроне (выбор источника истины)
"""
from django.contrib import admin, messages
from django.utils.html import format_html
from django.urls import reverse
from django.db.models import OuterRef, Exists
from employees.models import Employee, LdapSyncState
from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit
class LdapSyncStateInline(admin.TabularInline):
"""Inline для просмотра состояния синхронизации."""
model = LdapSyncState
extra = 0
can_delete = False
fields = (
'model',
'object_pk',
'last_sync_dir',
'last_ldap_modify_ts',
'last_django_modify_ts',
'updated_at',
)
readonly_fields = fields
def has_add_permission(self, request, obj=None):
return False
@admin.register(LdapUser)
class LdapUserAdmin(admin.ModelAdmin):
"""Админка для LDAP пользователей с поддержкой синхронизации.
Возможности:
- Просмотр всех LDAP-атрибутов
- Ручное редактирование (для исправления рассинхрона)
- Actions для синхронизации с выбором источника истины
"""
list_display = (
'cn_display',
'sam_account_name',
'mail',
'django_employee_link',
'sync_status',
'account_status',
)
list_filter = (
'user_account_control',
)
search_fields = (
'cn',
'sam_account_name',
'mail',
'employee_number',
'given_name',
'sn',
)
readonly_fields = (
'dn_display',
'member_of_display',
'sync_info',
)
fieldsets = (
('🔑 Идентификация', {
'fields': (
'dn_display',
'cn',
'sam_account_name',
'user_principal_name',
'employee_number',
)
}),
('👤 Персональные данные', {
'fields': (
'given_name',
'sn',
'display_name',
'mail',
)
}),
('📞 Контакты', {
'fields': (
'telephone_number',
'mobile',
)
}),
('⚙️ Управление учетной записью', {
'fields': (
'user_account_control',
'description',
)
}),
('🖼️ Дополнительно', {
'classes': ('collapse',),
'fields': (
'thumbnail_photo',
)
}),
('👥 Членство в группах', {
'classes': ('collapse',),
'fields': (
'member_of_display',
)
}),
('🔄 Статус синхронизации', {
'fields': (
'sync_info',
)
}),
)
actions = [
'sync_from_ldap_to_django',
'sync_from_django_to_ldap',
'show_sync_diff',
]
# Пагинация (LDAP может быть медленным)
list_per_page = 100
# Кастомные поля для отображения
def cn_display(self, obj):
"""CN с иконкой статуса."""
if obj.cn:
return format_html('👤 {}', obj.cn)
return '-'
cn_display.short_description = 'CN (Common Name)'
def dn_display(self, obj):
"""DN с подсветкой компонентов."""
if not obj.dn:
return '-'
# Разбиваем DN на части для читаемости
parts = obj.dn.split(',')
html_parts = []
for part in parts:
if part.startswith('CN='):
html_parts.append(format_html('<strong style="color: #0066cc;">{}</strong>', part))
elif part.startswith('OU='):
html_parts.append(format_html('<span style="color: #666;">{}</span>', part))
elif part.startswith('DC='):
html_parts.append(format_html('<span style="color: #999;">{}</span>', part))
else:
html_parts.append(part)
return format_html(','.join(html_parts))
dn_display.short_description = 'Distinguished Name'
def member_of_display(self, obj):
"""Список групп с форматированием."""
if not obj.member_of:
return format_html('<em style="color: #999;">Не состоит в группах</em>')
groups_html = []
for group_dn in obj.member_of[:10]: # Ограничиваем первыми 10
# Извлекаем CN из DN
cn = group_dn.split(',')[0].replace('CN=', '')
groups_html.append(format_html('• {}', cn))
result = '<br>'.join(groups_html)
if len(obj.member_of) > 10:
result += format_html('<br><em>...и ещё {} групп</em>', len(obj.member_of) - 10)
return format_html(result)
member_of_display.short_description = 'Членство в группах'
def django_employee_link(self, obj):
"""Ссылка на Django Employee если есть."""
if not obj.employee_number:
return format_html('<em style="color: #999;">-</em>')
try:
emp = Employee.objects.get(pk=int(obj.employee_number))
url = reverse('admin:employees_employee_change', args=[emp.pk])
return format_html(
'<a href="{}" style="color: #0066cc;">👤 {} {}</a>',
url,
emp.first_name,
emp.last_name
)
except (Employee.DoesNotExist, ValueError):
return format_html(
'<em style="color: #cc6600;">⚠️ ID {} (не найден)</em>',
obj.employee_number
)
django_employee_link.short_description = 'Django Employee'
def sync_status(self, obj):
"""Статус синхронизации с Django."""
if not obj.employee_number:
return format_html('<span style="color: #999;">❌ Не связан</span>')
try:
sync_state = LdapSyncState.objects.get(
model='employee',
object_pk=obj.employee_number
)
if sync_state.last_sync_dir == 'django_to_ldap':
icon = '⬆️'
color = '#0066cc'
text = 'Django → LDAP'
elif sync_state.last_sync_dir == 'ldap_to_django':
icon = '⬇️'
color = '#00cc66'
text = 'LDAP → Django'
else:
icon = '❓'
color = '#999'
text = 'Неизвестно'
return format_html(
'<span style="color: {};">{} {}</span><br>'
'<small style="color: #666;">{}</small>',
color, icon, text,
sync_state.updated_at.strftime('%Y-%m-%d %H:%M')
)
except LdapSyncState.DoesNotExist:
return format_html('<span style="color: #cc6600;">⚠️ Нет записи</span>')
sync_status.short_description = 'Синхронизация'
def account_status(self, obj):
"""Статус учетной записи (активна/заблокирована)."""
# UAC флаг ACCOUNTDISABLE = 0x2 (2)
is_disabled = bool(obj.user_account_control and (obj.user_account_control & 2))
if is_disabled:
return format_html('<span style="color: #cc0000;">🔒 Заблокирована</span>')
else:
return format_html('<span style="color: #00cc00;">✅ Активна</span>')
account_status.short_description = 'Статус'
def sync_info(self, obj):
"""Подробная информация о синхронизации."""
if not obj.employee_number:
return format_html(
'<div style="padding: 10px; background: #fff3cd; border-left: 4px solid #ffc107;">'
'⚠️ <strong>Не связан с Django Employee</strong><br>'
'<small>Этот LDAP-пользователь не привязан к записи в БД Django.</small>'
'</div>'
)
try:
emp = Employee.objects.get(pk=int(obj.employee_number))
sync_state = LdapSyncState.objects.filter(
model='employee',
object_pk=obj.employee_number
).first()
if not sync_state:
return format_html(
'<div style="padding: 10px; background: #f8d7da; border-left: 4px solid #dc3545;">'
'❌ <strong>Нет записи синхронизации</strong><br>'
'<small>Связь с Employee ID {} установлена, но LdapSyncState отсутствует.</small>'
'</div>',
obj.employee_number
)
return format_html(
'<div style="padding: 10px; background: #d1ecf1; border-left: 4px solid #17a2b8;">'
'✅ <strong>Синхронизирован</strong><br>'
'<table style="margin-top: 5px;">'
'<tr><td><strong>Employee ID:</strong></td><td>{}</td></tr>'
'<tr><td><strong>LDAP DN:</strong></td><td><code>{}</code></td></tr>'
'<tr><td><strong>Последняя синхронизация:</strong></td><td>{}</td></tr>'
'<tr><td><strong>Направление:</strong></td><td>{}</td></tr>'
'</table>'
'</div>',
emp.pk,
sync_state.ldap_dn or '-',
sync_state.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
sync_state.last_sync_dir or 'не указано'
)
except Employee.DoesNotExist:
return format_html(
'<div style="padding: 10px; background: #f8d7da; border-left: 4px solid #dc3545;">'
'❌ <strong>Employee не найден</strong><br>'
'<small>employee_number={} указывает на несуществующую запись.</small>'
'</div>',
obj.employee_number
)
sync_info.short_description = 'Информация о синхронизации'
# Actions для синхронизации
@admin.action(description='🔄 Синхронизировать LDAP → Django (LDAP как источник истины)')
def sync_from_ldap_to_django(self, request, queryset):
"""Синхронизирует выбранных пользователей из LDAP в Django.
LDAP считается источником истины - данные из Active Directory
перезапишут данные в Django Employee.
"""
from employees.ldap.services import UserService
success_count = 0
error_count = 0
for ldap_user in queryset:
if not ldap_user.employee_number:
error_count += 1
continue
try:
emp = Employee.objects.get(pk=int(ldap_user.employee_number))
# Обновляем Django из LDAP
emp.first_name = ldap_user.given_name or emp.first_name
emp.last_name = ldap_user.sn or emp.last_name
emp.email = ldap_user.mail or emp.email
emp.phone_number = ldap_user.telephone_number or ldap_user.mobile or emp.phone_number
emp.save()
# Обновляем LdapSyncState
LdapSyncState.objects.update_or_create(
model='employee',
object_pk=str(emp.pk),
defaults={
'ldap_dn': ldap_user.dn,
'last_sync_dir': 'ldap_to_django',
}
)
success_count += 1
except Exception as e:
error_count += 1
self.message_user(
request,
f'Ошибка синхронизации {ldap_user.cn}: {e}',
level=messages.ERROR
)
if success_count:
self.message_user(
request,
f'Успешно синхронизировано: {success_count} пользователей (LDAP → Django)',
level=messages.SUCCESS
)
if error_count:
self.message_user(
request,
f'Ошибок: {error_count}',
level=messages.WARNING
)
@admin.action(description='🔄 Синхронизировать Django → LDAP (Django как источник истины)')
def sync_from_django_to_ldap(self, request, queryset):
"""Синхронизирует выбранных пользователей из Django в LDAP.
Django считается источником истины - данные из БД
перезапишут данные в Active Directory.
"""
from employees.ldap.services import UserService
success_count = 0
error_count = 0
service = UserService()
for ldap_user in queryset:
if not ldap_user.employee_number:
error_count += 1
continue
try:
emp = Employee.objects.get(pk=int(ldap_user.employee_number))
# Обновляем LDAP из Django через сервис
changes = {
'first_name': emp.first_name,
'last_name': emp.last_name,
'email': emp.email,
'phone_number': emp.phone_number,
}
service.update_user(emp, changes)
success_count += 1
except Exception as e:
error_count += 1
self.message_user(
request,
f'Ошибка синхронизации {ldap_user.cn}: {e}',
level=messages.ERROR
)
if success_count:
self.message_user(
request,
f'Успешно синхронизировано: {success_count} пользователей (Django → LDAP)',
level=messages.SUCCESS
)
if error_count:
self.message_user(
request,
f'Ошибок: {error_count}',
level=messages.WARNING
)
@admin.action(description='🔍 Показать различия LDAP ↔ Django')
def show_sync_diff(self, request, queryset):
"""Показывает различия между LDAP и Django для выбранных пользователей."""
diffs = []
for ldap_user in queryset:
if not ldap_user.employee_number:
continue
try:
emp = Employee.objects.get(pk=int(ldap_user.employee_number))
user_diffs = []
# Сравниваем поля
if ldap_user.given_name != emp.first_name:
user_diffs.append(f"Имя: LDAP='{ldap_user.given_name}' vs Django='{emp.first_name}'")
if ldap_user.sn != emp.last_name:
user_diffs.append(f"Фамилия: LDAP='{ldap_user.sn}' vs Django='{emp.last_name}'")
if ldap_user.mail != emp.email:
user_diffs.append(f"Email: LDAP='{ldap_user.mail}' vs Django='{emp.email}'")
ldap_phone = ldap_user.telephone_number or ldap_user.mobile
if ldap_phone and ldap_phone != emp.phone_number:
user_diffs.append(f"Телефон: LDAP='{ldap_phone}' vs Django='{emp.phone_number}'")
if user_diffs:
diffs.append(f"{ldap_user.cn}: " + ", ".join(user_diffs))
except Employee.DoesNotExist:
diffs.append(f"{ldap_user.cn}: Employee ID {ldap_user.employee_number} не найден")
if diffs:
self.message_user(
request,
"Найдены различия:\n" + "\n".join(diffs),
level=messages.WARNING
)
else:
self.message_user(
request,
"Различий не найдено. Все выбранные пользователи синхронизированы.",
level=messages.SUCCESS
)
# Переопределение разрешений (опционально - можно разрешить редактирование)
def has_add_permission(self, request):
"""Запрещаем создание через админку (используйте signals)."""
return False
def has_delete_permission(self, request, obj=None):
"""Разрешаем удаление только суперпользователям."""
return request.user.is_superuser
def get_queryset(self, request):
"""Базовый queryset без slice - пагинация через list_per_page."""
return super().get_queryset(request)
# TODO: Добавить LdapGroupAdmin и LdapOrganizationalUnitAdmin аналогично