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: НЕТ - обратная совместимость сохранена
477 lines
18 KiB
Python
477 lines
18 KiB
Python
"""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 аналогично
|