1. Добавлена колонка ldap_sync_status в список сотрудников с иконками статуса (✅/⚠️/❌) 2. В детальной информации ldap_sync_info добавлена кликабельная ссылка на запись LdapSyncState 3. Создан кастомный фильтр LdapSyncedFilter для быстрой фильтрации сотрудников по наличию записи синхронизации 4. Улучшена читаемость статусов синхронизации с подсказками при наведении
506 lines
16 KiB
Python
506 lines
16 KiB
Python
# 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
|