Игорь Надеин a142d0ccbc Улучшена админка для мониторинга LDAP синхронизации:
1. Добавлена колонка ldap_sync_status в список сотрудников с иконками статуса (✅/⚠️/❌)
2. В детальной информации ldap_sync_info добавлена кликабельная ссылка на запись LdapSyncState
3. Создан кастомный фильтр LdapSyncedFilter для быстрой фильтрации сотрудников по наличию записи синхронизации
4. Улучшена читаемость статусов синхронизации с подсказками при наведении
2025-12-02 10:32:56 +03:00

506 lines
16 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.

# backend/employees/admin.py
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import ReadOnlyPasswordHashField, AdminPasswordChangeForm
from django.contrib.auth.models import Group, Permission
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from simple_history.admin import SimpleHistoryAdmin
from .models import (
Employee,
EmployeeAction,
Department,
DepartmentRole,
EmployeeDepartment,
Position,
Skill,
LdapSyncState,
)
# =========================
# Формы для кастомного User (Employee)
# =========================
class LdapSyncedFilter(admin.SimpleListFilter):
"""Фильтр для отображения сотрудников по состоянию LDAP синхронизации."""
title = "Статус LDAP синхронизации"
parameter_name = "ldap_synced"
def lookups(self, request, model_admin):
return (
("yes", "Есть запись синхронизации"),
("no", "Нет записи синхронизации"),
)
def queryset(self, request, queryset):
from django.db.models import Exists, OuterRef
if self.value() == "yes":
# Показываем только тех, у кого есть запись синхронизации
return queryset.filter(
Exists(
LdapSyncState.objects.filter(
model="employee",
object_pk=OuterRef("pk")
)
)
)
elif self.value() == "no":
# Показываем только тех, у кого нет записи синхронизации
return queryset.exclude(
Exists(
LdapSyncState.objects.filter(
model="employee",
object_pk=OuterRef("pk")
)
)
)
return queryset
class EmployeeCreationForm(forms.ModelForm):
"""
Создание сотрудника в админке: логин — email, без username.
"""
password1 = forms.CharField(label="Пароль", widget=forms.PasswordInput)
password2 = forms.CharField(label="Подтверждение пароля", widget=forms.PasswordInput)
class Meta:
model = Employee
fields = ("email", "first_name", "last_name", "phone_number")
def clean_password2(self):
p1 = self.cleaned_data.get("password1") or ""
p2 = self.cleaned_data.get("password2") or ""
if not p1:
raise forms.ValidationError("Пароль обязателен")
if p1 != p2:
raise forms.ValidationError("Пароли не совпадают")
return p2
def save(self, commit=True):
user = super().save(commit=False)
user.set_password(self.cleaned_data["password1"])
# При создании через админку обычно активируем и помечаем e-mail подтверждённым
if not user.is_active:
user.is_active = True
if not user.email_verified:
user.email_verified = True
if commit:
user.save()
return user
class EmployeeChangeForm(forms.ModelForm):
"""
Редактирование сотрудника: пароль только для чтения (смена пароля — отдельной кнопкой).
"""
password = ReadOnlyPasswordHashField(
label="Хэш пароля",
help_text=(
"Пароль хранится в виде хэша — посмотреть его невозможно. "
"Используйте кнопку «Изменить пароль» выше."
),
required=False,
)
class Meta:
model = Employee
fields = (
"email",
"password",
"first_name",
"last_name",
"patronymic",
"gender",
"birth_date",
"avatar",
"phone_number",
"telegram",
"whatsapp",
"wechat",
"position",
"skills",
"is_active",
"email_verified",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
"last_login",
)
def clean_password(self):
# Возвращаем исходное значение (хэш), даже если пользователь ничего не ввёл
return self.initial.get("password")
# =========================
# Инлайны
# =========================
class EmployeeDepartmentInline(admin.TabularInline):
"""
Принадлежности сотрудника к отделам (видно из карточки сотрудника).
"""
model = EmployeeDepartment
fk_name = "employee"
extra = 0
autocomplete_fields = ("department", "role")
fields = ("department", "role", "is_active", "date_from", "date_to")
show_change_link = True
class DepartmentMembershipInline(admin.TabularInline):
"""
Состав отдела — список участников прямо в карточке отдела.
"""
model = EmployeeDepartment
fk_name = "department"
extra = 0
autocomplete_fields = ("employee", "role")
fields = ("employee", "role", "is_active", "date_from", "date_to")
show_change_link = True
# =========================
# Админки моделей
# =========================
@admin.register(Employee)
class EmployeeAdmin(DjangoUserAdmin):
"""
Кастомный UserAdmin для модели Employee.
Включает стандартную кнопку «Изменить пароль».
"""
add_form = EmployeeCreationForm
form = EmployeeChangeForm
model = Employee
# Включаем стандартную форму смены пароля и шаблон,
# который показывает ссылку «Изменить пароль» на форме изменения пользователя.
change_password_form = AdminPasswordChangeForm
change_form_template = "admin/employees/employee/change_form.html"
list_display = (
"email",
"last_name",
"first_name",
"phone_number",
"position",
"is_active",
"email_verified",
"is_ldap_managed",
"ldap_sync_status",
"is_staff",
)
list_filter = (
"is_active",
"email_verified",
"is_staff",
"is_superuser",
"is_ldap_managed",
LdapSyncedFilter,
"position",
"groups",
)
search_fields = ("email", "first_name", "last_name", "phone_number")
ordering = ("last_name", "first_name")
list_select_related = ("position",)
filter_horizontal = ("groups", "user_permissions", "skills")
readonly_fields = (
"last_login",
"created_at",
"updated_at",
"ldap_sync_info",
"email_activation_code",
"sms_activation_code",
)
def ldap_sync_status(self, obj):
"""Краткий статус LDAP синхронизации для списка."""
if not obj.pk or not obj.is_ldap_managed:
return "-"
try:
sync_state = LdapSyncState.objects.filter(
model="employee",
object_pk=str(obj.pk)
).first()
if not sync_state:
return "❌ Нет записи"
from django.utils.html import format_html
# Показываем GUID и дату последнего обновления
if sync_state.ldap_guid and sync_state.updated_at:
return format_html(
'✅ <span title="GUID: {}">{}</span>',
sync_state.ldap_guid,
sync_state.updated_at.strftime("%d.%m.%Y %H:%M")
)
elif sync_state.updated_at:
return format_html(
'⚠️ {}',
sync_state.updated_at.strftime("%d.%m.%Y %H:%M")
)
else:
return "⚠️ Неполные данные"
except Exception:
return "❌ Ошибка"
ldap_sync_status.short_description = "LDAP статус"
ldap_sync_status.admin_order_field = "id" # Сортировка по ID
fieldsets = (
(None, {"fields": ("email", "password")}),
("Личная информация", {
"fields": (
"first_name",
"last_name",
"patronymic",
"gender",
"birth_date",
"avatar",
)
}),
("Контакты", {
"fields": ("phone_number", "telegram", "whatsapp", "wechat")
}),
("Работа", {
"fields": ("position", "skills")
}),
("Права и статусы", {
"fields": (
"is_active",
"email_verified",
"email_activation_code",
"sms_activation_code",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
}),
("LDAP", {
"fields": ("is_ldap_managed", "ldap_sync_info"),
"classes": ("collapse",),
}),
("Служебное", {"fields": ("last_login", "created_at", "updated_at")}),
)
def ldap_sync_info(self, obj):
"""Отображает информацию о состоянии синхронизации LDAP."""
if not obj.pk:
return "-"
try:
sync_state = LdapSyncState.objects.filter(
model="employee",
object_pk=str(obj.pk)
).first()
if not sync_state:
return "Нет записи синхронизации"
from django.utils.html import format_html
from django.urls import reverse
# Ссылка на запись синхронизации в админке
sync_url = reverse(
'admin:employees_ldapsyncstate_change',
args=[sync_state.pk]
)
info = [
format_html(
'<a href="{}" target="_blank">📋 Открыть запись синхронизации</a>',
sync_url
)
]
if sync_state.ldap_dn:
info.append(f"<b>DN:</b> {sync_state.ldap_dn}")
if sync_state.ldap_guid:
info.append(f"<b>GUID:</b> {sync_state.ldap_guid}")
if sync_state.last_sync_dir:
info.append(f"<b>Направление:</b> {sync_state.last_sync_dir}")
if sync_state.last_ldap_modify_ts:
info.append(
f"<b>LDAP изменен:</b> {sync_state.last_ldap_modify_ts}"
)
if sync_state.last_django_modify_ts:
info.append(
f"<b>Django изменен:</b> {sync_state.last_django_modify_ts}"
)
if sync_state.updated_at:
info.append(f"<b>Обновлено:</b> {sync_state.updated_at}")
return format_html("<br>".join(info)) if info else "-"
except Exception as e:
return f"Ошибка: {e}"
ldap_sync_info.short_description = "Состояние LDAP синхронизации"
add_fieldsets = (
(None, {
"classes": ("wide",),
"fields": (
"email",
"first_name",
"last_name",
"phone_number",
"whatsapp",
"position",
"password1",
"password2",
"is_active",
"email_verified",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
),
}),
)
inlines = [EmployeeDepartmentInline,]
@admin.register(Position)
class PositionAdmin(SimpleHistoryAdmin):
list_display = ("name", "description")
search_fields = ("name", "description", "ldap_group_dn")
filter_horizontal = ("groups",)
fieldsets = (
(None, {"fields": ("name", "description")}),
("Права", {"fields": ("groups",)}),
("LDAP", {
"fields": ("ldap_group_dn",),
"classes": ("collapse",),
}),
)
@admin.register(EmployeeAction)
class EmployeeActionAdmin(SimpleHistoryAdmin):
list_display = ("employee", "action", "date")
list_filter = ("action",)
search_fields = ("employee__first_name", "employee__last_name", "comment")
autocomplete_fields = ("employee",)
date_hierarchy = "date"
@admin.register(Department)
class DepartmentAdmin(admin.ModelAdmin):
list_display = ("name", "head", "head_appointed_at", "created_at")
list_filter = ("head",)
search_fields = ("name", "description", "ldap_group_dn")
autocomplete_fields = ("head",)
readonly_fields = ("created_at", "head_appointed_at")
inlines = [DepartmentMembershipInline]
fieldsets = (
(None, {"fields": ("name", "description")}),
("Руководство", {
"fields": ("head", "head_appointed_at"),
}),
("LDAP", {
"fields": ("ldap_group_dn",),
"classes": ("collapse",),
}),
("Служебное", {"fields": ("created_at",)}),
)
@admin.register(DepartmentRole)
class DepartmentRoleAdmin(admin.ModelAdmin):
list_display = ("name", "department")
list_filter = ("department",)
search_fields = ("name", "department__name", "ldap_group_dn")
autocomplete_fields = ("department",)
filter_horizontal = ("scoped_permissions",)
fieldsets = (
(None, {"fields": ("department", "name")}),
("Права", {"fields": ("scoped_permissions",)}),
("LDAP", {
"fields": ("ldap_group_dn",),
"classes": ("collapse",),
}),
)
@admin.register(EmployeeDepartment)
class EmployeeDepartmentAdmin(admin.ModelAdmin):
list_display = ("employee", "department", "role", "is_active", "date_from", "date_to")
list_filter = ("is_active", "department", "role")
search_fields = ("employee__first_name", "employee__last_name", "department__name")
autocomplete_fields = ("employee", "department", "role")
date_hierarchy = "date_from"
@admin.register(Skill)
class SkillAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name",)
# Полезно иметь Permission в админке
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
list_display = ("name", "codename", "content_type")
list_filter = ("content_type",)
search_fields = ("name", "codename")
@admin.register(LdapSyncState)
class LdapSyncStateAdmin(admin.ModelAdmin):
"""Админка для просмотра состояния синхронизации LDAP."""
list_display = (
"model",
"object_pk",
"ldap_dn_short",
"ldap_guid",
"last_sync_dir",
"updated_at",
)
list_filter = ("model", "last_sync_dir")
search_fields = ("object_pk", "ldap_dn", "ldap_guid")
readonly_fields = (
"model",
"object_pk",
"ldap_dn",
"ldap_guid",
"last_ldap_modify_ts",
"last_django_modify_ts",
"last_sync_dir",
"data_hash",
"updated_at",
)
def ldap_dn_short(self, obj):
"""Показываем сокращенную версию DN для удобства."""
if obj.ldap_dn:
return obj.ldap_dn[:50] + "..." if len(obj.ldap_dn) > 50 else obj.ldap_dn
return "-"
ldap_dn_short.short_description = "LDAP DN"
def has_add_permission(self, request):
"""Запрещаем создание вручную."""
return False
def has_delete_permission(self, request, obj=None):
"""Разрешаем удаление для очистки."""
return True