lizerk 130dafc5a2 Fix: Replace delete_selected with LDAP-safe custom action
Django's delete_selected passes queryset with dn__in filter that
ldapdb doesn't support. Custom delete_selected_ldap reads DNs from
POST and deletes objects one by one. Also hides standard delete_selected
from action choices.
2026-03-20 02:20:42 +03:00

576 lines
22 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-данных
- Ручного редактирования полей (с осторожностью)
- Синхронизации при рассинхроне (выбор источника истины)
"""
import base64
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
from .utils.ldap_utils import get_ldap_str
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',
'thumbnail_photo_display',
)
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_display',
)
}),
('👥 Членство в группах', {
'classes': ('collapse',),
'fields': (
'member_of_display',
)
}),
('🔄 Статус синхронизации', {
'fields': (
'sync_info',
)
}),
)
actions = [
'delete_selected_ldap',
'sync_from_ldap_to_django',
'sync_from_django_to_ldap',
'show_sync_diff',
]
# Пагинация (LDAP может быть медленным)
list_per_page = 100
def _resolve_ldap_users(self, request):
"""Получает выбранные LDAP объекты по DN из POST данных.
ldapdb не поддерживает dn__in lookup, поэтому
получаем каждый объект по DN отдельно.
"""
selected_dns = request.POST.getlist('_selected_action')
users = []
for dn in selected_dns:
try:
user = LdapUser.objects.get(dn=dn)
users.append(user)
except LdapUser.DoesNotExist:
pass
return users
# Кастомные поля для отображения
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':
icon = '⬆️'
color = '#0066cc'
text = 'Django → LDAP'
elif sync_state.last_sync_dir == 'ldap':
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 thumbnail_photo_display(self, obj):
"""Превью аватара из LDAP thumbnailPhoto."""
data = obj.thumbnail_photo
if not data or not isinstance(data, (bytes, bytearray)):
return format_html(
'<em style="color:#999;">Нет фото</em>'
)
b64 = base64.b64encode(data).decode('ascii')
return format_html(
'<img src="data:image/jpeg;base64,{}" '
'style="max-width:150px;max-height:150px;'
'border-radius:8px;" />',
b64,
)
thumbnail_photo_display.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.
"""
success_count = 0
error_count = 0
ldap_users = self._resolve_ldap_users(request)
for ldap_user in ldap_users:
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 = get_ldap_str(ldap_user.given_name) or emp.first_name
emp.last_name = get_ldap_str(ldap_user.sn) or emp.last_name
emp.email = get_ldap_str(ldap_user.mail) or emp.email
ldap_phone = get_ldap_str(ldap_user.telephone_number or ldap_user.mobile)
emp.phone_number = ldap_phone 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',
}
)
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()
ldap_users = self._resolve_ldap_users(request)
for ldap_user in ldap_users:
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 = []
ldap_users = self._resolve_ldap_users(request)
for ldap_user in ldap_users:
if not ldap_user.employee_number:
continue
try:
emp = Employee.objects.get(pk=int(ldap_user.employee_number))
user_diffs = []
# Сравниваем поля
ldap_first = get_ldap_str(ldap_user.given_name)
if ldap_first != emp.first_name:
user_diffs.append(f"Имя: LDAP='{ldap_first}' vs Django='{emp.first_name}'")
ldap_last = get_ldap_str(ldap_user.sn)
if ldap_last != emp.last_name:
user_diffs.append(f"Фамилия: LDAP='{ldap_last}' vs Django='{emp.last_name}'")
ldap_email = get_ldap_str(ldap_user.mail)
if ldap_email != emp.email:
user_diffs.append(f"Email: LDAP='{ldap_email}' vs Django='{emp.email}'")
ldap_phone = get_ldap_str(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_action_choices(self, request, default_choices=admin.ModelAdmin.action_form):
"""Убираем стандартный delete_selected (ldapdb не поддерживает dn__in)."""
choices = super().get_action_choices(request, default_choices)
return [
c for c in choices
if not (isinstance(c[0], str) and c[0] == 'delete_selected')
]
@admin.action(description='🗑️ Удалить выбранных пользователей из LDAP')
def delete_selected_ldap(self, request, queryset):
"""Кастомное удаление — обходит dn__in, читает DN из POST."""
users = self._resolve_ldap_users(request)
if not users:
self.message_user(
request, 'Не удалось найти выбранных пользователей.',
level=messages.WARNING,
)
return
if request.POST.get('post') != 'yes':
# Показываем confirmation page
from django.template.response import TemplateResponse
context = {
**self.admin_site.each_context(request),
'title': 'Подтверждение удаления',
'objects_name': 'LDAP пользователей',
'deletable_objects': [str(u) for u in users],
'queryset': users,
'opts': self.model._meta,
'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
'media': self.media,
}
request.current_app = self.admin_site.name
return TemplateResponse(
request,
'admin/ldap_delete_confirmation.html',
context,
)
deleted = 0
for user in users:
try:
user.delete()
deleted += 1
except Exception as e:
self.message_user(
request,
f'Ошибка удаления {user.cn}: {e}',
level=messages.ERROR,
)
if deleted:
self.message_user(
request,
f'Удалено: {deleted} пользователей из LDAP.',
level=messages.SUCCESS,
)
def get_queryset(self, request):
"""Базовый queryset без slice - пагинация через list_per_page."""
return super().get_queryset(request)
# TODO: Добавить LdapGroupAdmin и LdapOrganizationalUnitAdmin аналогично