2524 lines
90 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 urllib.parse import quote
from django.contrib import admin, messages
from django.utils.html import format_html
from django.urls import reverse
from employees.models import Employee, LdapSyncState
from .orm_models import (
LdapUser,
LdapGroup,
LdapOrganizationalUnitGroup,
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 для синхронизации с выбором источника истины
"""
def get_urls(self):
"""Добавляем URL для смены пароля конкретного пользователя."""
from django.urls import re_path
urls = [
re_path(
r"^(.+)/password/$",
self.admin_site.admin_view(self.user_change_password),
name="ldapuser_password_change",
),
]
return urls + super().get_urls()
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",
"password_change_link",
)
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",
"password_change_link",
)
},
),
(
"🖼️ Дополнительно",
{"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",
"change_password_action",
]
# Пагинация (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 = "Информация о синхронизации"
def password_change_link(self, obj):
"""Ссылка на форму смены пароля (как в стандартной Django admin)."""
if not obj.dn:
return "-"
# Кодируем DN для URL
from urllib.parse import quote
dn_encoded = quote(obj.dn, safe="")
change_password_url = reverse(
"admin:ldapuser_password_change",
args=[dn_encoded],
)
return format_html(
'<div style="margin: 10px 0;">'
'<a href="{}" class="button" style="'
"display: inline-block; "
"padding: 10px 20px; "
"background: #417690; "
"color: white; "
"text-decoration: none; "
"border-radius: 4px; "
"font-weight: bold;"
'">'
"🔑 Изменить пароль"
"</a>"
"</div>"
'<p style="color: #666; margin-top: 10px;">'
"Пароль будет изменён в LDAP и автоматически "
"синхронизирован с Django БД."
"</p>",
change_password_url,
)
password_change_link.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
warnings = []
ldap_users = self._resolve_ldap_users(request)
for ldap_user in ldap_users:
if not ldap_user.employee_number:
error_count += 1
warnings.append(
f"⚠️ {
ldap_user.cn
}: отсутствует employeeNumber (связь с Django)"
)
continue
# Проверка обязательных полей в LDAP
missing_fields = []
if not get_ldap_str(ldap_user.given_name):
missing_fields.append("givenName (имя)")
if not get_ldap_str(ldap_user.sn):
missing_fields.append("sn (фамилия)")
if not get_ldap_str(ldap_user.mail):
missing_fields.append("mail (email)")
if missing_fields:
warnings.append(
f"⚠️ {ldap_user.cn}: отсутствуют поля в LDAP: {
', '.join(missing_fields)
}"
)
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 warnings:
for warning in warnings[:10]: # Первые 10 предупреждений
self.message_user(request, warning, level=messages.WARNING)
if len(warnings) > 10:
self.message_user(
request,
f"...и ещё {len(warnings) - 10} предупреждений",
level=messages.WARNING,
)
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
warnings = []
service = UserService()
ldap_users = self._resolve_ldap_users(request)
for ldap_user in ldap_users:
if not ldap_user.employee_number:
error_count += 1
warnings.append(
f"⚠️ {
ldap_user.cn
}: отсутствует employeeNumber (связь с Django)"
)
continue
try:
emp = Employee.objects.get(pk=int(ldap_user.employee_number))
# Проверка обязательных полей в Django
missing_fields = []
if not emp.first_name or not emp.first_name.strip():
missing_fields.append("first_name (имя)")
if not emp.last_name or not emp.last_name.strip():
missing_fields.append("last_name (фамилия)")
if not emp.email or not emp.email.strip():
missing_fields.append("email")
if missing_fields:
warnings.append(
f"⚠️ Employee #{emp.pk}: отсутствуют поля: {
', '.join(missing_fields)
}"
)
# Обновляем LDAP из Django через сервис
changes = {
"first_name": emp.first_name,
"last_name": emp.last_name,
"email": emp.email,
"phone_number": emp.phone_number,
"is_active": emp.is_active and emp.is_actually_active,
}
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 warnings:
for warning in warnings[:10]: # Первые 10 предупреждений
self.message_user(request, warning, level=messages.WARNING)
if len(warnings) > 10:
self.message_user(
request,
f"...и ещё {len(warnings) - 10} предупреждений",
level=messages.WARNING,
)
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="🔑 Изменить пароль выбранных пользователей")
def change_password_action(self, request, queryset):
"""Изменяет пароль выбранных LDAP пользователей.
Показывает форму для ввода нового пароля и применяет его
к выбранным пользователям через AD extended operation.
"""
ldap_users = self._resolve_ldap_users(request)
if not ldap_users:
self.message_user(
request,
"❌ Не удалось найти выбранных пользователей",
level=messages.ERROR,
)
return
# Если форма не отправлена - показываем её
if "new_password" not in request.POST:
from django import forms
from django.template.response import TemplateResponse
class PasswordForm(forms.Form):
new_password = forms.CharField(
label="Новый пароль",
widget=forms.PasswordInput(
attrs={"autocomplete": "new-password"}
),
min_length=7,
help_text=(
"Минимум 7 символов, должен соответствовать "
"политике AD"
),
)
confirm_password = forms.CharField(
label="Подтверждение пароля",
widget=forms.PasswordInput(
attrs={"autocomplete": "new-password"}
),
)
def clean(self):
cleaned = super().clean()
pwd1 = cleaned.get("new_password")
pwd2 = cleaned.get("confirm_password")
if pwd1 and pwd2 and pwd1 != pwd2:
raise forms.ValidationError("Пароли не совпадают")
return cleaned
form = PasswordForm()
context = {
**self.admin_site.each_context(request),
"title": "Изменение пароля LDAP пользователей",
"form": form,
"users": ldap_users,
"users_count": len(ldap_users),
"action_name": "change_password_action",
"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_change_password.html",
context,
)
# Форма отправлена - обрабатываем
new_password = request.POST.get("new_password", "").strip()
confirm_password = request.POST.get("confirm_password", "").strip()
if not new_password:
self.message_user(
request, "❌ Пароль не может быть пустым", level=messages.ERROR
)
return
if new_password != confirm_password:
self.message_user(
request, "❌ Пароли не совпадают", level=messages.ERROR
)
return
if len(new_password) < 7:
self.message_user(
request,
"❌ Пароль должен содержать минимум 7 символов",
level=messages.ERROR,
)
return
# Применяем пароль ко всем выбранным пользователям
success_count = 0
error_count = 0
for ldap_user in ldap_users:
try:
ldap_user.set_password(new_password)
success_count += 1
# Обновляем Django Employee если есть связь
if ldap_user.employee_number:
try:
emp = Employee.objects.get(
pk=int(ldap_user.employee_number)
)
emp.set_password(new_password)
emp.save(update_fields=["password"])
except (Employee.DoesNotExist, ValueError):
pass # Не критично
except ValueError as e:
error_count += 1
self.message_user(
request, f"⚠️ {ldap_user.cn}: {e}", level=messages.WARNING
)
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} пользователей",
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}' "
f"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 "
f"{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 user_change_password(self, request, dn_encoded):
"""Форма смены пароля для конкретного LDAP пользователя."""
from django import forms
from django.template.response import TemplateResponse
from django.http import HttpResponseRedirect
from urllib.parse import unquote
# Декодируем DN
dn = unquote(dn_encoded)
# Находим пользователя
try:
user = LdapUser.objects.get(dn=dn)
except LdapUser.DoesNotExist:
self.message_user(
request, "❌ Пользователь не найден", level=messages.ERROR
)
return HttpResponseRedirect("../../") # Возврат на список
# Форма для смены пароля
class PasswordChangeForm(forms.Form):
password1 = forms.CharField(
label="Новый пароль",
widget=forms.PasswordInput(
attrs={"autocomplete": "new-password"}
),
min_length=7,
help_text=(
"Минимум 7 символов, должен соответствовать "
"политике AD"
),
)
password2 = forms.CharField(
label="Подтверждение пароля",
widget=forms.PasswordInput(
attrs={"autocomplete": "new-password"}
),
)
def clean(self):
cleaned = super().clean()
pwd1 = cleaned.get("password1")
pwd2 = cleaned.get("password2")
if pwd1 and pwd2 and pwd1 != pwd2:
raise forms.ValidationError("Пароли не совпадают")
return cleaned
if request.method == "POST":
form = PasswordChangeForm(request.POST)
if form.is_valid():
new_password = form.cleaned_data["password1"]
try:
# Меняем пароль в LDAP
user.set_password(new_password)
# Синхронизируем с Django Employee
if user.employee_number:
try:
emp = Employee.objects.get(
pk=int(user.employee_number)
)
emp.set_password(new_password)
emp.save(update_fields=["password"])
except (Employee.DoesNotExist, ValueError):
pass # Не критично
self.message_user(
request,
f"✅ Пароль успешно изменён для {user.cn}",
level=messages.SUCCESS,
)
# Перенаправляем на список пользователей.
# Избегаем проблем с кодировкой DN.
return HttpResponseRedirect("../../")
except ValueError as e:
form.add_error(None, f"Ошибка валидации: {e}")
except Exception as e:
form.add_error(None, f"Ошибка LDAP: {e}")
else:
form = PasswordChangeForm()
context = {
**self.admin_site.each_context(request),
"title": f"Изменение пароля: {user.cn}",
"form": form,
"user": user,
"opts": self.model._meta,
"original": user,
"media": self.media,
}
return TemplateResponse(
request,
"admin/ldap_user_password_change.html",
context,
)
# Переопределение разрешений (опционально - можно разрешить редактирование)
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=None):
"""Убираем стандартный delete_selected.
ldapdb не поддерживает dn__in.
"""
if default_choices is None:
from django.db import models
default_choices = models.BLANK_CHOICE_DASH
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)
@admin.register(LdapOrganizationalUnit)
class LdapOrganizationalUnitAdmin(admin.ModelAdmin):
"""Админка для LDAP Organizational Units (отделов).
Возможности:
- Просмотр всех OU в Active Directory
- Ручное редактирование (описание, managedBy)
- Связь с Django Department
- Actions для синхронизации и удаления
"""
def get_urls(self):
"""Переопределяем URLs для корректной работы с DN как PK."""
urls = super().get_urls()
return urls
list_display = (
"ou_display",
"description_short",
"managed_by_display",
"django_department_link",
"when_changed",
)
search_fields = (
"ou",
"description",
"managed_by",
)
readonly_fields = (
"dn_display",
"managed_by_link",
"django_department_info",
)
fieldsets = (
(
"🔑 Идентификация",
{
"fields": (
"dn_display",
"ou",
)
},
),
("📝 Описание", {"fields": ("description",)}),
(
"👤 Руководитель (managedBy)",
{
"fields": (
"managed_by",
"managed_by_link",
)
},
),
("🔗 Связь с Django", {"fields": ("django_department_info",)}),
)
actions = [
"delete_selected_ldap",
"sync_from_ldap_to_django",
"sync_from_django_to_ldap",
"show_sync_diff",
]
list_per_page = 100
def _resolve_ldap_ous(self, request):
"""Получает выбранные LDAP OU по DN из POST данных.
ldapdb не поддерживает dn__in lookup, поэтому
получаем каждый объект по DN отдельно.
"""
selected_dns = request.POST.getlist("_selected_action")
ous = []
for dn in selected_dns:
try:
ou = LdapOrganizationalUnit.objects.get(dn=dn)
ous.append(ou)
except LdapOrganizationalUnit.DoesNotExist:
pass
return ous
def _find_django_department(self, ldap_ou):
"""Ищет Django Department по имени OU или по ldap_dn в SyncState."""
from employees.models import Department
# Сначала пробуем по LdapSyncState
sync = LdapSyncState.objects.filter(
model="department",
ldap_dn=ldap_ou.dn,
).first()
if sync:
try:
return Department.objects.get(pk=int(sync.object_pk))
except (Department.DoesNotExist, ValueError):
pass
# Fallback: по совпадению имени
try:
return Department.objects.get(name=ldap_ou.ou)
except Department.DoesNotExist:
return None
# --- Кастомные поля для отображения ---
def ou_display(self, obj):
"""Имя OU с иконкой."""
return format_html("🏢 {}", obj.ou) if obj.ou else "-"
ou_display.short_description = "OU (Отдел)"
ou_display.admin_order_field = "ou"
def description_short(self, obj):
"""Сокращённое описание."""
desc = get_ldap_str(obj.description) or ""
if len(desc) > 80:
return desc[:80] + "…"
return desc or format_html('<em style="color:#999;">—</em>')
description_short.short_description = "Описание"
def dn_display(self, obj):
"""DN с подсветкой компонентов."""
if not obj.dn:
return "-"
parts = obj.dn.split(",")
html_parts = []
for part in parts:
if part.startswith("OU="):
html_parts.append(
format_html(
'<strong style="color:#0066cc;">{}</strong>',
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 managed_by_display(self, obj):
"""Сокращённое отображение managedBy в списке."""
mb = get_ldap_str(obj.managed_by)
if not mb:
return format_html('<em style="color:#999;">—</em>')
# Извлекаем CN из DN
cn = mb.split(",")[0].replace("CN=", "")
return format_html("👤 {}", cn)
managed_by_display.short_description = "Руководитель"
def managed_by_link(self, obj):
"""Подробная информация о руководителе с ссылкой."""
mb = get_ldap_str(obj.managed_by)
if not mb:
return format_html('<em style="color:#999;">Не назначен</em>')
# Пробуем найти LdapUser по DN
try:
ldap_user = LdapUser.objects.get(dn=mb)
# Правильный URL pattern для LDAP моделей
url = reverse(
"admin:employees_ldapuser_change",
args=[quote(ldap_user.dn, safe="")],
)
return format_html(
'<a href="{}">👤 {} ({})</a><br>'
'<code style="font-size:0.85em;color:#666;">{}</code>',
url,
ldap_user.display_name or ldap_user.cn,
ldap_user.sam_account_name or "",
mb,
)
except LdapUser.DoesNotExist:
return format_html(
'<code style="color:#666;">{}</code>',
mb,
)
managed_by_link.short_description = "Руководитель (подробно)"
def django_department_link(self, obj):
"""Ссылка на Django Department в списке."""
dept = self._find_django_department(obj)
if dept:
url = reverse("admin:employees_department_change", args=[dept.pk])
return format_html(
'<a href="{}" style="color:#0066cc;">🏢 {}</a>',
url,
dept.name,
)
return format_html('<em style="color:#999;">—</em>')
django_department_link.short_description = "Django Department"
def django_department_info(self, obj):
"""Подробная информация о связи с Django Department."""
dept = self._find_django_department(obj)
if not dept:
return format_html(
'<div style="padding:10px;background:#fff3cd;'
'border-left:4px solid #ffc107;">'
"⚠️ <strong>Не связан с Django Department</strong><br>"
"<small>Этот LDAP OU не привязан к записи в БД.</small>"
"</div>"
)
sync_state = LdapSyncState.objects.filter(
model="department",
object_pk=str(dept.pk),
).first()
return format_html(
'<div style="padding:10px;background:#d1ecf1;'
'border-left:4px solid #17a2b8;">'
"✅ <strong>Связан с Django Department</strong><br>"
'<table style="margin-top:5px;">'
"<tr><td><strong>Department ID:</strong></td><td>{}</td></tr>"
"<tr><td><strong>Название:</strong></td><td>{}</td></tr>"
"<tr><td><strong>Руководитель:</strong></td><td>{}</td></tr>"
"<tr><td><strong>Последняя синхронизация:</strong></td>"
"<td>{}</td></tr>"
"</table></div>",
dept.pk,
dept.name,
dept.head or "—",
sync_state.updated_at.strftime("%Y-%m-%d %H:%M:%S")
if sync_state
else "—",
)
django_department_info.short_description = "Связь с Django"
# --- Actions ---
@admin.action(
description=(
"🔄 Синхронизировать LDAP → Django "
"(LDAP как источник истины)"
)
)
def sync_from_ldap_to_django(self, request, queryset):
"""Синхронизирует выбранные OU из LDAP в Django Department."""
from employees.models import Department
success_count = 0
error_count = 0
warnings = []
ldap_ous = self._resolve_ldap_ous(request)
for ldap_ou in ldap_ous:
ou_name = get_ldap_str(ldap_ou.ou)
if not ou_name:
error_count += 1
warnings.append(f"⚠️ {ldap_ou.dn}: пустое имя OU")
continue
try:
dept = self._find_django_department(ldap_ou)
if not dept:
# Создаём Department
dept = Department(name=ou_name)
dept.description = (
get_ldap_str(ldap_ou.description) or dept.description
)
# Пытаемся найти руководителя
mb = get_ldap_str(ldap_ou.managed_by)
if mb:
try:
mgr = LdapUser.objects.get(dn=mb)
if mgr.employee_number:
from employees.models import Employee
try:
dept.head = Employee.objects.get(
pk=int(mgr.employee_number),
)
except (Employee.DoesNotExist, ValueError):
warnings.append(
f"⚠️ {ou_name}: руководитель Employee #{
mgr.employee_number
} не найден",
)
except LdapUser.DoesNotExist:
warnings.append(
f"⚠️ {ou_name}: managedBy DN не найден в LDAP",
)
dept._skip_ldap_sync = True
dept.save()
LdapSyncState.objects.update_or_create(
model="department",
object_pk=str(dept.pk),
defaults={
"ldap_dn": ldap_ou.dn,
"last_sync_dir": "ldap",
},
)
success_count += 1
except Exception as e:
error_count += 1
self.message_user(
request,
f"Ошибка синхронизации {ou_name}: {e}",
level=messages.ERROR,
)
for w in warnings[:10]:
self.message_user(request, w, level=messages.WARNING)
if len(warnings) > 10:
self.message_user(
request,
f"...и ещё {len(warnings) - 10} предупреждений",
level=messages.WARNING,
)
if success_count:
self.message_user(
request,
(
f"Успешно синхронизировано: {success_count} OU "
"(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):
"""Синхронизирует выбранные OU из Django Department в LDAP."""
success_count = 0
error_count = 0
warnings = []
ldap_ous = self._resolve_ldap_ous(request)
for ldap_ou in ldap_ous:
dept = self._find_django_department(ldap_ou)
if not dept:
error_count += 1
warnings.append(
f"⚠️ {ldap_ou.dn}: не найден связанный Django Department",
)
continue
missing_fields = []
if not dept.name or not dept.name.strip():
missing_fields.append("name (название)")
if missing_fields:
warnings.append(
f"⚠️ Department #{dept.pk}: отсутствуют поля: {
', '.join(missing_fields)
}",
)
try:
ldap_ou.description = dept.description or ""
# Обновляем managedBy
if dept.head:
sync = LdapSyncState.objects.filter(
model="employee",
object_pk=str(dept.head.pk),
).first()
if sync and sync.ldap_dn:
ldap_ou.managed_by = sync.ldap_dn
else:
warnings.append(
f"⚠️ {dept.name}: руководитель не имеет LDAP DN",
)
else:
ldap_ou.managed_by = ""
ldap_ou.save()
LdapSyncState.objects.update_or_create(
model="department",
object_pk=str(dept.pk),
defaults={
"ldap_dn": ldap_ou.dn,
"last_sync_dir": "django",
},
)
success_count += 1
except Exception as e:
error_count += 1
self.message_user(
request,
f"Ошибка синхронизации {ldap_ou.ou}: {e}",
level=messages.ERROR,
)
for w in warnings[:10]:
self.message_user(request, w, level=messages.WARNING)
if len(warnings) > 10:
self.message_user(
request,
f"...и ещё {len(warnings) - 10} предупреждений",
level=messages.WARNING,
)
if success_count:
self.message_user(
request,
(
f"Успешно синхронизировано: {success_count} OU "
"(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 OU и Django Department."""
diffs = []
ldap_ous = self._resolve_ldap_ous(request)
for ldap_ou in ldap_ous:
dept = self._find_django_department(ldap_ou)
if not dept:
diffs.append(
f"{ldap_ou.ou}: не найден связанный Django Department"
)
continue
ou_diffs = []
ldap_desc = get_ldap_str(ldap_ou.description) or ""
if ldap_desc != (dept.description or ""):
ou_diffs.append(
"Описание: "
f"LDAP='{ldap_desc[:50]}' vs "
f"Django='{(dept.description or '')[:50]}'"
)
# Сравниваем руководителя
mb = get_ldap_str(ldap_ou.managed_by)
if dept.head:
head_sync = LdapSyncState.objects.filter(
model="employee",
object_pk=str(dept.head.pk),
).first()
head_dn = head_sync.ldap_dn if head_sync else None
if mb != head_dn:
ou_diffs.append(
f"Руководитель: LDAP='{mb or '—'}' vs Django='{
dept.head
}'"
)
elif mb:
ou_diffs.append(
f"Руководитель: LDAP='{mb}' vs Django='не назначен'"
)
if ou_diffs:
diffs.append(f"{ldap_ou.ou}: " + ", ".join(ou_diffs))
if diffs:
self.message_user(
request,
"Найдены различия:\n" + "\n".join(diffs),
level=messages.WARNING,
)
else:
self.message_user(
request,
"Различий не найдено. Все выбранные OU синхронизированы.",
level=messages.SUCCESS,
)
# --- Permissions & overrides ---
def has_add_permission(self, request):
"""Запрещаем создание OU через админку."""
return False
def has_delete_permission(self, request, obj=None):
"""Удаление только суперпользователям."""
return request.user.is_superuser
def get_action_choices(self, request, default_choices=None):
"""Убираем стандартный delete_selected.
ldapdb не поддерживает dn__in.
"""
if default_choices is None:
from django.db import models
default_choices = models.BLANK_CHOICE_DASH
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="🗑️ Удалить выбранные OU из LDAP")
def delete_selected_ldap(self, request, queryset):
"""Кастомное удаление — обходит dn__in, читает DN из POST."""
ous = self._resolve_ldap_ous(request)
if not ous:
self.message_user(
request,
"Не удалось найти выбранные OU.",
level=messages.WARNING,
)
return
if request.POST.get("post") != "yes":
from django.template.response import TemplateResponse
context = {
**self.admin_site.each_context(request),
"title": "Подтверждение удаления",
"objects_name": "LDAP Organizational Units",
"deletable_objects": [str(o) for o in ous],
"queryset": ous,
"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 ou_obj in ous:
try:
ou_obj.delete()
deleted += 1
except Exception as e:
self.message_user(
request,
f"Ошибка удаления {ou_obj.ou}: {e}",
level=messages.ERROR,
)
if deleted:
self.message_user(
request,
f"Удалено: {deleted} OU из LDAP.",
level=messages.SUCCESS,
)
def get_queryset(self, request):
"""Базовый queryset."""
return super().get_queryset(request)
def changelist_view(self, request, extra_context=None):
"""Переопределяем для фильтрации корневого контейнера."""
from .orm_models import get_departments_base
# Сохраняем base DN для использования в get_results
self._exclude_base_dn = get_departments_base().lower()
return super().changelist_view(request, extra_context)
def get_changelist_instance(self, request):
"""Создаём кастомный changelist с фильтрацией."""
from django.contrib.admin.views.main import ChangeList
class FilteredChangeList(ChangeList):
"""ChangeList с фильтрацией base DN."""
def get_results(self, request):
super().get_results(request)
# Фильтруем результаты после получения
if hasattr(self.model_admin, "_exclude_base_dn"):
exclude = self.model_admin._exclude_base_dn
self.result_list = [
obj
for obj in self.result_list
if obj.dn and obj.dn.lower() != exclude
]
self.result_count = len(self.result_list)
self.full_result_count = self.result_count
list_display = self.get_list_display(request)
list_display_links = self.get_list_display_links(request, list_display)
list_filter = self.get_list_filter(request)
search_fields = self.get_search_fields(request)
list_select_related = self.get_list_select_related(request)
return FilteredChangeList(
request,
self.model,
list_display,
list_display_links,
list_filter,
self.date_hierarchy,
search_fields,
list_select_related,
self.list_per_page,
self.list_max_show_all,
self.list_editable,
self,
self.sortable_by,
self.search_help_text,
)
@admin.register(LdapOrganizationalUnitGroup)
class LdapOrganizationalUnitGroupAdmin(admin.ModelAdmin):
"""Админка для LDAP групп OU (DEP_*).
Эти группы содержат всех активных сотрудников отделов.
Автоматически создаются и синхронизируются при
синхронизации Department → LDAP.
Возможности:
- Просмотр состава группы
- Связь с Django Department и OU
- Просмотр членов группы
"""
def get_urls(self):
"""Переопределяем URLs для корректной работы с DN как PK."""
urls = super().get_urls()
return urls
list_display = (
"group_display",
"description_short",
"members_count",
"ou_link",
"django_department_link",
"when_changed",
)
search_fields = (
"cn",
"description",
"sam_account_name",
)
readonly_fields = (
"dn_display",
"cn",
"sam_account_name",
"members_list",
"ou_info",
"django_department_info",
)
fieldsets = (
(
"🔑 Идентификация",
{
"fields": (
"dn_display",
"cn",
"sam_account_name",
)
},
),
("📝 Описание", {"fields": ("description",)}),
("👥 Состав группы", {"fields": ("members_list",)}),
("🏢 Связь с OU", {"fields": ("ou_info",)}),
("🔗 Связь с Django", {"fields": ("django_department_info",)}),
)
list_per_page = 100
actions = [
"show_members_info",
"sync_members_from_django",
]
def has_add_permission(self, request):
"""Группы создаются автоматически через OU."""
return False
def has_delete_permission(self, request, obj=None):
"""Удаление через LDAP OU admin."""
return False
def _resolve_ou_groups(self, request):
"""Получает выбранные OU группы по DN из POST данных."""
selected_dns = request.POST.getlist("_selected_action")
groups = []
for dn in selected_dns:
try:
group = LdapOrganizationalUnitGroup.objects.get(dn=dn)
groups.append(group)
except LdapOrganizationalUnitGroup.DoesNotExist:
pass
return groups
# --- Actions ---
def show_members_info(self, request, queryset):
"""Показывает информацию об участниках."""
groups = self._resolve_ou_groups(request)
for group in groups:
members = group.member or []
self.message_user(
request,
f"👥 {group.cn}: {len(members)} участников",
level=messages.INFO,
)
show_members_info.short_description = "📋 Показать состав"
def sync_members_from_django(self, request, queryset):
"""Синхронизирует участников из Django EmployeeDepartment."""
groups = self._resolve_ou_groups(request)
if not groups:
self.message_user(
request,
"Не удалось найти выбранные группы.",
level=messages.WARNING,
)
return
from employees.models import EmployeeDepartment, Department
for group in groups:
cn = get_ldap_str(group.cn) or ""
if not cn.startswith("DEP_"):
continue
dept_name = cn[4:]
try:
dept = Department.objects.get(name=dept_name)
except Department.DoesNotExist:
self.message_user(
request,
f'⚠️ {cn}: Django Department "{dept_name}" не найден',
level=messages.WARNING,
)
continue
active_links = EmployeeDepartment.objects.filter(
department=dept,
is_active=True,
).select_related("employee")
member_dns = []
for link in active_links:
if not (
link.employee.is_active and link.employee.is_actually_active
):
continue
emp_sync = LdapSyncState.objects.filter(
model="employee",
object_pk=str(link.employee_id),
).first()
if emp_sync and emp_sync.ldap_dn:
member_dns.append(emp_sync.ldap_dn)
result = group.sync_members(member_dns)
self.message_user(
request,
f"👥 {cn}: +{result['added']}/−{result['removed']} "
f"(всего: {len(member_dns)})",
level=messages.SUCCESS,
)
sync_members_from_django.short_description = (
"🔄 Синхронизировать участников из Django"
)
def _find_django_department(self, ou_group):
"""Находит Department по имени группы DEP_*."""
from employees.models import Department
# Извлекаем имя отдела из CN группы
cn = get_ldap_str(ou_group.cn) or ""
if cn.startswith("DEP_"):
dept_name = cn[4:] # Убираем префикс DEP_
try:
return Department.objects.get(name=dept_name)
except Department.DoesNotExist:
pass
return None
def _find_ldap_ou(self, ou_group):
"""Находит LdapOrganizationalUnit по DN группы."""
# DN группы: CN=DEP_IT,OU=IT,OU=Departments,...
# OU DN: OU=IT,OU=Departments,...
dn_parts = ou_group.dn.split(",", 1)
if len(dn_parts) == 2:
ou_dn = dn_parts[1]
try:
return LdapOrganizationalUnit.objects.get(dn=ou_dn)
except LdapOrganizationalUnit.DoesNotExist:
pass
return None
# --- Кастомные поля для отображения ---
def group_display(self, obj):
"""Имя группы с иконкой."""
cn = get_ldap_str(obj.cn) or ""
return format_html("👥 {}", cn)
group_display.short_description = "Группа OU"
group_display.admin_order_field = "cn"
def description_short(self, obj):
"""Сокращённое описание."""
desc = get_ldap_str(obj.description) or ""
if len(desc) > 60:
return desc[:60] + "…"
return desc or format_html('<em style="color:#999;">—</em>')
description_short.short_description = "Описание"
def members_count(self, obj):
"""Количество участников."""
members = obj.member or []
count = len(members)
if count == 0:
return format_html('<span style="color:#999;">0</span>')
return format_html(
'<strong style="color:#28a745;">{}</strong>',
count,
)
members_count.short_description = "Участников"
def dn_display(self, obj):
"""DN с подсветкой."""
if not obj.dn:
return "-"
parts = obj.dn.split(",")
html_parts = []
for part in parts:
if part.startswith("CN="):
html_parts.append(
format_html(
'<strong style="color:#28a745;">{}</strong>',
part,
)
)
elif part.startswith("OU="):
html_parts.append(
format_html(
'<span style="color:#0066cc;">{}</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 members_list(self, obj):
"""Список участников группы с ссылками."""
members = obj.member or []
if not members:
return format_html('<em style="color:#999;">Нет участников</em>')
html = '<div style="max-height:400px;overflow-y:auto;">'
html += '<ol style="margin:0;padding-left:20px;">'
for member_dn in members[:50]: # Первые 50
try:
ldap_user = LdapUser.objects.get(dn=member_dn)
url = reverse(
"admin:employees_ldapuser_change",
args=[ldap_user.dn],
)
html += format_html(
'<li><a href="{}">{} ({})</a></li>',
url,
ldap_user.display_name or ldap_user.cn or "?",
ldap_user.sam_account_name or "",
)
except LdapUser.DoesNotExist:
html += format_html(
'<li><code style="color:#dc3545;">{}</code> '
"<em>(не найден)</em></li>",
member_dn,
)
if len(members) > 50:
html += format_html(
"<li><em>...и ещё {} участников</em></li>",
len(members) - 50,
)
html += "</ol></div>"
return format_html(html)
members_list.short_description = "Участники группы"
def ou_link(self, obj):
"""Ссылка на родительскую OU."""
ldap_ou = self._find_ldap_ou(obj)
if ldap_ou:
url = reverse(
"admin:employees_ldaporganizationalunit_change",
args=[ldap_ou.dn],
)
return format_html(
'<a href="{}" style="color:#0066cc;">🏢 {}</a>',
url,
ldap_ou.ou,
)
return format_html('<em style="color:#999;">—</em>')
ou_link.short_description = "OU"
def ou_info(self, obj):
"""Информация о родительской OU."""
ldap_ou = self._find_ldap_ou(obj)
if not ldap_ou:
return format_html(
'<div style="padding:10px;background:#f8d7da;'
'border-left:4px solid #dc3545;">'
"❌ <strong>OU не найдена</strong><br>"
"<small>Родительская Organizational Unit не существует "
"или недоступна.</small>"
"</div>"
)
url = reverse(
"admin:employees_ldaporganizationalunit_change",
args=[ldap_ou.dn],
)
return format_html(
'<div style="padding:10px;background:#d1ecf1;'
'border-left:4px solid #17a2b8;">'
"✅ <strong>Связана с OU</strong><br>"
'<table style="margin-top:5px;">'
"<tr><td><strong>OU:</strong></td>"
'<td><a href="{}">{}</a></td></tr>'
"<tr><td><strong>DN:</strong></td>"
"<td><code>{}</code></td></tr>"
"</table></div>",
url,
ldap_ou.ou,
ldap_ou.dn,
)
ou_info.short_description = "Информация об OU"
def django_department_link(self, obj):
"""Ссылка на Django Department."""
dept = self._find_django_department(obj)
if dept:
url = reverse(
"admin:employees_department_change",
args=[dept.pk],
)
return format_html(
'<a href="{}" style="color:#0066cc;">🏢 {}</a>',
url,
dept.name,
)
return format_html('<em style="color:#999;">—</em>')
django_department_link.short_description = "Django Department"
def django_department_info(self, obj):
"""Информация о Django Department."""
dept = self._find_django_department(obj)
if not dept:
return format_html(
'<div style="padding:10px;background:#fff3cd;'
'border-left:4px solid #ffc107;">'
"⚠️ <strong>Department не найден</strong><br>"
"<small>Эта группа не связана с отделом в БД.</small>"
"</div>"
)
url = reverse(
"admin:employees_department_change",
args=[dept.pk],
)
return format_html(
'<div style="padding:10px;background:#d4edda;'
'border-left:4px solid #28a745;">'
"✅ <strong>Связана с Department</strong><br>"
'<table style="margin-top:5px;">'
"<tr><td><strong>ID:</strong></td><td>{}</td></tr>"
"<tr><td><strong>Название:</strong></td>"
'<td><a href="{}">{}</a></td></tr>'
"<tr><td><strong>Руководитель:</strong></td>"
"<td>{}</td></tr>"
"</table></div>",
dept.pk,
url,
dept.name,
dept.head or "—",
)
django_department_info.short_description = "Связь с Django"
@admin.register(LdapGroup)
class LdapGroupAdmin(admin.ModelAdmin):
"""Админка для LDAP групп (глобальные + роли отделов).
Охватывает:
- Глобальные группы: CN=GroupName,OU=Groups,...
- Роли отделов: CN=ROLE_*,OU=<Dept>,OU=Departments,...
НЕ охватывает группы отделов DEP_*.
Для них используется LdapOrganizationalUnitGroupAdmin.
Возможности:
- Просмотр состава группы
- Управление членством
- Просмотр вложенности групп
"""
def get_urls(self):
"""Переопределяем URLs для корректной работы с DN как PK."""
urls = super().get_urls()
return urls
list_display = (
"group_display",
"sam_account_name",
"description_short",
"group_type_display",
"members_count",
"when_changed",
)
list_filter = (("cn", admin.EmptyFieldListFilter),)
search_fields = (
"cn",
"sam_account_name",
"description",
)
readonly_fields = (
"dn_display",
"cn",
"sam_account_name",
"when_created",
"when_changed",
"members_list",
"member_of_list",
)
fieldsets = (
(
"🔑 Идентификация",
{
"fields": (
"dn_display",
"cn",
"sam_account_name",
)
},
),
("📝 Описание", {"fields": ("description",)}),
("👥 Участники группы", {"fields": ("members_list",)}),
(
"📊 Членство в группах",
{"classes": ("collapse",), "fields": ("member_of_list",)},
),
(
"📅 Временные метки",
{
"classes": ("collapse",),
"fields": (
"when_created",
"when_changed",
),
},
),
)
list_per_page = 100
actions = [
"show_group_info",
"sync_groups_from_ldap",
"sync_groups_to_ldap",
"delete_selected_ldap",
]
def has_add_permission(self, request):
"""Группы создаются через GroupService или автоматически."""
return False
def has_delete_permission(self, request, obj=None):
"""Удаление через LDAP services."""
return False
def _resolve_ldap_groups(self, request):
"""Получает выбранные LDAP группы по DN из POST данных."""
selected_dns = request.POST.getlist("_selected_action")
groups = []
for dn in selected_dns:
try:
group = LdapGroup.objects.get(dn=dn)
groups.append(group)
except LdapGroup.DoesNotExist:
pass
return groups
# --- Actions ---
def show_group_info(self, request, queryset):
"""Показывает информацию о выбранных группах."""
groups = self._resolve_ldap_groups(request)
if not groups:
self.message_user(
request,
"Не удалось найти выбранные группы.",
level=messages.WARNING,
)
return
for group in groups:
members = group.member or []
member_of = group.member_of or []
self.message_user(
request,
f"👥 {group.cn}: {len(members)} участников, "
f"входит в {len(member_of)} групп | DN: {group.dn}",
level=messages.INFO,
)
show_group_info.short_description = "📋 Показать информацию"
def delete_selected_ldap(self, request, queryset):
"""Удаляет выбранные LDAP группы."""
groups = self._resolve_ldap_groups(request)
if not groups:
self.message_user(
request,
"Не удалось найти выбранные группы.",
level=messages.WARNING,
)
return
deleted = 0
for group in groups:
try:
group.delete()
deleted += 1
except Exception as e:
self.message_user(
request,
f"Ошибка удаления {group.cn}: {e}",
level=messages.ERROR,
)
if deleted:
self.message_user(
request,
f"Удалено: {deleted} групп из LDAP.",
level=messages.SUCCESS,
)
delete_selected_ldap.short_description = "🗑️ Удалить из LDAP"
def sync_groups_from_ldap(self, request, queryset):
"""Синхронизирует ВСЕ группы из OU=Groups в Django Group."""
from employees.ldap.services.group_service import GroupService
svc = GroupService()
try:
created = svc.sync_catalog(throttle_seconds=0)
self.message_user(
request,
f"🔄 LDAP → Django: создано {created} новых Django Group",
level=messages.SUCCESS,
)
except Exception as e:
self.message_user(
request,
f"Ошибка синхронизации: {e}",
level=messages.ERROR,
)
sync_groups_from_ldap.short_description = (
"⬇️ LDAP → Django (создать Django Group)"
)
def sync_groups_to_ldap(self, request, queryset):
"""Синхронизирует Django Groups → LDAP.
Отсутствующие группы создаются после проверки LDAP.
"""
from django.contrib.auth.models import Group as DjangoGroup
from employees.ldap.services.group_service import GroupService
svc = GroupService()
groups = self._resolve_ldap_groups(request)
if not groups:
# Если ничего не выбрано — синхронизируем все Django Groups
django_groups = DjangoGroup.objects.all()
created = 0
already = 0
for dg in django_groups:
# Реальная проверка наличия в LDAP
exists_in_ldap = LdapGroup.objects.filter(cn=dg.name).exists()
if exists_in_ldap:
ldap_grp = LdapGroup.objects.filter(cn=dg.name).first()
LdapSyncState.objects.update_or_create(
model="group",
object_pk=str(dg.pk),
defaults={
"ldap_dn": ldap_grp.dn,
"last_sync_dir": "ldap",
},
)
already += 1
continue
try:
dn = svc.create(cn=dg.name)
LdapSyncState.objects.update_or_create(
model="group",
object_pk=str(dg.pk),
defaults={
"ldap_dn": dn,
"last_sync_dir": "django",
},
)
created += 1
except Exception as e:
self.message_user(
request,
f"⚠️ {dg.name}: {e}",
level=messages.WARNING,
)
self.message_user(
request,
f"🔄 Django → LDAP: создано {created}, "
f"уже существовало {already}",
level=messages.SUCCESS,
)
else:
# Показываем info о выбранных LDAP-группах
for group in groups:
sync = LdapSyncState.objects.filter(
model="group",
ldap_dn=group.dn,
).first()
if sync:
self.message_user(
request,
f"✅ {group.cn}: связана с Django Group pk={
sync.object_pk
}",
level=messages.INFO,
)
else:
self.message_user(
request,
f"⚠️ {group.cn}: нет связи с Django Group",
level=messages.WARNING,
)
sync_groups_to_ldap.short_description = (
"⬆️ Django → LDAP (создать LDAP Group)"
)
# --- Кастомные поля для отображения ---
def group_display(self, obj):
"""Имя группы с иконкой."""
cn = get_ldap_str(obj.cn) or ""
# Определяем тип по DN
if "OU=Groups" in obj.dn:
icon = "🌐" # Глобальная
elif cn.startswith("ROLE_"):
icon = "🎭" # Роль
elif cn.startswith("DEP_"):
icon = "🏢" # Отдел (хотя не должно быть здесь)
else:
icon = "👥"
return format_html("{} {}", icon, cn)
group_display.short_description = "Группа"
group_display.admin_order_field = "cn"
def group_type_display(self, obj):
"""Тип группы по расположению."""
cn = get_ldap_str(obj.cn) or ""
dn = obj.dn or ""
if "OU=Groups" in dn:
return format_html(
'<span style="color:#0066cc;">🌐 Глобальная</span>'
)
elif cn.startswith("ROLE_"):
# Извлекаем название отдела из DN
parts = dn.split(",")
dept = None
for i, part in enumerate(parts):
if part.startswith("OU=") and i > 0:
dept = part[3:]
break
if dept:
return format_html(
'<span style="color:#9c27b0;">🎭 Роль ({})</span>', dept
)
return format_html('<span style="color:#9c27b0;">🎭 Роль</span>')
elif cn.startswith("DEP_"):
return format_html(
'<span style="color:#ff9800;">🏢 Группа отдела</span>'
)
else:
return format_html('<span style="color:#999;">👥 Группа</span>')
group_type_display.short_description = "Тип"
def description_short(self, obj):
"""Сокращённое описание."""
desc = get_ldap_str(obj.description) or ""
if len(desc) > 60:
return desc[:60] + "…"
return desc or format_html('<em style="color:#999;">—</em>')
description_short.short_description = "Описание"
def members_count(self, obj):
"""Количество участников."""
members = obj.member or []
count = len(members)
if count == 0:
return format_html('<span style="color:#999;">0</span>')
return format_html(
'<strong style="color:#28a745;">{}</strong>',
count,
)
members_count.short_description = "Участников"
def dn_display(self, obj):
"""DN с подсветкой."""
if not obj.dn:
return "-"
parts = obj.dn.split(",")
html_parts = []
for part in parts:
if part.startswith("CN="):
html_parts.append(
format_html(
'<strong style="color:#28a745;">{}</strong>',
part,
)
)
elif part.startswith("OU="):
html_parts.append(
format_html(
'<span style="color:#0066cc;">{}</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 members_list(self, obj):
"""Список участников группы с ссылками."""
members = obj.member or []
if not members:
return format_html('<em style="color:#999;">Нет участников</em>')
html = '<div style="max-height:400px;overflow-y:auto;">'
html += '<ol style="margin:0;padding-left:20px;">'
for member_dn in members[:100]: # Первые 100
# Определяем тип участника
if member_dn.startswith("CN=") and ",OU=" in member_dn:
# Пользователь
try:
ldap_user = LdapUser.objects.get(dn=member_dn)
url = reverse(
"admin:employees_ldapuser_change",
args=[quote(ldap_user.dn, safe="")],
)
html += format_html(
'<li>👤 <a href="{}">{} ({})</a></li>',
url,
ldap_user.display_name or ldap_user.cn or "?",
ldap_user.sam_account_name or "",
)
except LdapUser.DoesNotExist:
# Может быть группа
try:
ldap_group = LdapGroup.objects.get(dn=member_dn)
url = reverse(
"admin:employees_ldapgroup_change",
args=[quote(ldap_group.dn, safe="")],
)
html += format_html(
'<li>👥 <a href="{}">{}</a></li>',
url,
ldap_group.cn or "?",
)
except LdapGroup.DoesNotExist:
html += format_html(
'<li><code style="color:#dc3545;">{}</code> '
"<em>(не найден)</em></li>",
member_dn,
)
else:
html += format_html(
"<li><code>{}</code></li>",
member_dn,
)
if len(members) > 100:
html += format_html(
"<li><em>...и ещё {} участников</em></li>",
len(members) - 100,
)
html += "</ol></div>"
return format_html(html)
members_list.short_description = "Участники группы"
def member_of_list(self, obj):
"""Список групп, в которых состоит эта группа."""
member_of = obj.member_of or []
if not member_of:
return format_html(
'<em style="color:#999;">Не состоит в группах</em>'
)
html = '<div style="max-height:300px;overflow-y:auto;">'
html += '<ul style="margin:0;padding-left:20px;">'
for group_dn in member_of[:50]:
# Извлекаем CN из DN
cn = group_dn.split(",")[0].replace("CN=", "")
try:
parent_group = LdapGroup.objects.get(dn=group_dn)
url = reverse(
"admin:employees_ldapgroup_change",
args=[quote(parent_group.dn, safe="")],
)
html += format_html(
'<li><a href="{}">👥 {}</a></li>',
url,
parent_group.cn or cn,
)
except LdapGroup.DoesNotExist:
html += format_html(
"<li><code>{}</code></li>",
cn,
)
if len(member_of) > 50:
html += format_html(
"<li><em>...и ещё {} групп</em></li>",
len(member_of) - 50,
)
html += "</ul></div>"
return format_html(html)
member_of_list.short_description = "Членство в группах"