2008 lines
75 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, messages
from django.contrib.auth.admin import GroupAdmin as DefaultGroupAdmin
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,
)
def _resolve_ldap_user_for_employee(employee):
"""Возвращает наиболее вероятную LDAP-модель сотрудника.
Идет через общий resolver: сначала sync-state DN, затем employee_number,
затем email/UPN fallback среди кандидатов.
"""
from api.v1.directory.services import _find_ldap_user
return _find_ldap_user(employee)
# =========================
# Формы для кастомного 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:
return "-"
from django.utils.html import format_html
import logging
logger = logging.getLogger(__name__)
# Если не управляется LDAP
if not obj.is_ldap_managed:
return format_html('<span style="color: gray;">Локальный</span>')
try:
# Логируем попытку поиска
logger.debug(
f"[ldap_sync_status] Searching for employee {obj.pk} "
f"(email: {obj.email})"
)
sync_state = LdapSyncState.objects.filter(
model="employee", object_pk=str(obj.pk)
).first()
logger.debug(
f"[ldap_sync_status] Employee {obj.pk}: "
f"sync_state={'found' if sync_state else 'not found'}"
)
if not sync_state:
# Попробуем найти любые записи для этого employee
all_syncs = LdapSyncState.objects.filter(
model="employee"
).values_list("object_pk", flat=True)
all_sync_object_pks = list(all_syncs)
logger.warning(
"[ldap_sync_status] No sync record for employee "
f"{obj.pk}. "
"All employee object_pks in sync table: "
f"{all_sync_object_pks}"
)
# Создаем ссылку для быстрого создания записи
from django.urls import reverse
create_url = reverse("admin:employees_ldapsyncstate_add")
return format_html(
'<span style="color: red;" '
'title="ID: {} - Нет записи синхронизации. '
'Кликните для создания.">'
'<a href="{}?model=employee&object_pk={}" '
'style="color: red;">❌ Нет записи (ID: {})</a>'
"</span>",
obj.pk,
create_url,
obj.pk,
obj.pk,
)
# Проверяем полноту данных
has_guid = bool(sync_state.ldap_guid)
has_dn = bool(sync_state.ldap_dn)
has_updated = bool(sync_state.updated_at)
# Полные данные
if has_guid and has_dn and has_updated:
return format_html(
'✅ <span title="ID: {}\nGUID: {}\nDN: {}">{}</span>',
obj.pk,
sync_state.ldap_guid,
sync_state.ldap_dn[:80] + "..."
if len(sync_state.ldap_dn) > 80
else sync_state.ldap_dn,
sync_state.updated_at.strftime("%d.%m.%Y %H:%M"),
)
# Неполные данные
elif has_updated:
missing = []
if not has_guid:
missing.append("GUID")
if not has_dn:
missing.append("DN")
return format_html(
'⚠️ <span title="ID: {}\nОтсутствует: {}">{}</span>',
obj.pk,
", ".join(missing),
sync_state.updated_at.strftime("%d.%m.%Y %H:%M"),
)
else:
return format_html(
'<span style="color: orange;" '
'title="ID: {} - Нет даты обновления">'
'⚠️ Неполные данные</span>',
obj.pk,
)
except Exception as e:
logger.exception(
f"[ldap_sync_status] Error for employee {obj.pk}: {e}"
)
return format_html(
'<span style="color: red;" '
'title="ID: {} - Ошибка: {}">❌ Ошибка</span>',
obj.pk,
str(e),
)
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(
(
"<b>Django изменен:</b> "
f"{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]
actions = [
"create_ldap_sync_records",
"check_ldap_sync_status",
"sync_from_django_to_ldap",
"sync_from_ldap_to_django",
"show_sync_diff",
]
def create_ldap_sync_records(self, request, queryset):
"""Создать записи синхронизации для выбранных сотрудников."""
created_count = 0
already_exists = 0
for employee in queryset:
if not employee.is_ldap_managed:
continue
sync_state, created = LdapSyncState.objects.get_or_create(
model="employee",
object_pk=str(employee.pk),
defaults={
"last_sync_dir": "django",
},
)
if created:
created_count += 1
else:
already_exists += 1
if created_count:
self.message_user(
request,
f"Создано {created_count} записей синхронизации",
level="success",
)
if already_exists:
self.message_user(
request,
(
f"У {already_exists} сотрудников уже есть записи "
"синхронизации"
),
level="info",
)
if created_count == 0 and already_exists == 0:
self.message_user(
request,
"Выбраны только локальные пользователи (не управляются LDAP)",
level="warning",
)
create_ldap_sync_records.short_description = (
"Создать записи LDAP "
"синхронизации"
)
def check_ldap_sync_status(self, request, queryset):
"""Проверить статус LDAP синхронизации для выбранных сотрудников."""
ldap_managed = queryset.filter(is_ldap_managed=True)
ldap_count = ldap_managed.count()
if ldap_count == 0:
self.message_user(
request,
"Среди выбранных нет пользователей, управляемых через LDAP",
level="warning",
)
return
# Проверяем наличие записей синхронизации
sync_records = LdapSyncState.objects.filter(
model="employee", object_pk__in=[str(e.pk) for e in ldap_managed]
)
has_sync = sync_records.count()
missing_sync = ldap_count - has_sync
# Проверяем полноту данных
complete = (
sync_records.filter(ldap_guid__isnull=False, ldap_dn__isnull=False)
.exclude(ldap_dn="")
.count()
)
incomplete = has_sync - complete
message = "Статус LDAP синхронизации:\n"
message += f"• Управляются LDAP: {ldap_count}\n"
message += f"• Есть запись синхронизации: {has_sync}\n"
message += f"• Нет записи синхронизации: {missing_sync}\n"
message += f"• Полные данные: {complete}\n"
message += f"• Неполные данные: {incomplete}"
self.message_user(request, message, level="info")
check_ldap_sync_status.short_description = (
"Проверить статус LDAP синхронизации"
)
@admin.action(
description=(
"🔄 Синхронизировать Django → LDAP "
"(создать/обновить в Active Directory)"
)
)
def sync_from_django_to_ldap(self, request, queryset):
"""Создает или обновляет пользователей в LDAP из Django.
Django считается источником истины - данные из БД будут
записаны в Active Directory (создание или обновление).
"""
import logging
from employees.ldap.services import UserService
from employees.ldap.orm_models import LdapUser
from employees.ldap.utils.ldap_utils import get_ldap_str
logger = logging.getLogger(__name__)
success_count = 0
created_count = 0
updated_count = 0
error_count = 0
service = UserService()
for employee in queryset:
logger.info(
f"[sync_from_django_to_ldap] Начинаем обработку employee ID={
employee.pk
}, email={employee.email}"
)
try:
# Ищем существующего LDAP пользователя
ldap_user = None
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: is_ldap_managed={employee.is_ldap_managed}"
)
if employee.is_ldap_managed:
# Пробуем найти по employee_number
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: поиск в LDAP по employee_number={employee.pk}"
)
ldap_user = _resolve_ldap_user_for_employee(employee)
if ldap_user:
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: найден LDAP user с DN={ldap_user.dn}"
)
else:
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: не найден по employee_number"
)
if ldap_user:
# ОБНОВЛЕНИЕ существующего LDAP пользователя
logger.info(
f"[sync_from_django_to_ldap] Employee {employee.pk}: "
"LDAP пользователь существует, начинаем проверку "
"изменений"
)
try:
changes = {}
ldap_first = get_ldap_str(ldap_user.given_name)
if ldap_first != employee.first_name:
changes["first_name"] = employee.first_name
ldap_last = get_ldap_str(ldap_user.sn)
if ldap_last != employee.last_name:
changes["last_name"] = employee.last_name
ldap_email = get_ldap_str(ldap_user.mail)
if ldap_email != employee.email:
changes["email"] = employee.email
ldap_phone = get_ldap_str(
ldap_user.telephone_number or ldap_user.mobile
)
if (
employee.phone_number
and ldap_phone != employee.phone_number
):
changes["phone_number"] = employee.phone_number
if changes:
service.update_user(employee, changes)
updated_count += 1
# Проверяем, в правильной ли OU находится пользователь
from django.conf import settings as _settings
expected_base = None
if not employee.is_actually_active:
expected_base = getattr(
_settings, "LDAP_DISMISSED_BASE", None
)
else:
active_dept = employee.departments.first()
if active_dept:
dept_sync = LdapSyncState.objects.filter(
model="department",
object_pk=str(active_dept.pk),
).first()
if dept_sync and dept_sync.ldap_dn:
expected_base = dept_sync.ldap_dn
else:
depts_base = getattr(
_settings, "LDAP_DEPARTMENTS_BASE", ""
)
if depts_base:
expected_base = (
f"OU={active_dept.name},"
f"{depts_base}"
)
if expected_base:
# Текущий base_dn = DN без первой компоненты
# (CN=...)
current_parts = ldap_user.dn.split(",", 1)
current_base = (
current_parts[1]
if len(current_parts) > 1
else ""
)
if current_base.lower() != expected_base.lower():
try:
ldap_user.move_to(expected_base)
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: "
"перемещён из "
f"{current_base} в {expected_base}"
)
self.message_user(
request,
f"📦 {employee.email}: перемещён "
f"в {expected_base}",
level=messages.INFO,
)
# Добавляем в группу DEP_* нового отдела
try:
from employees.ldap.orm_models import (
LdapOrganizationalUnit,
)
ou = (
LdapOrganizationalUnit.objects.get(
dn=expected_base
)
)
dept_group = (
ou.ensure_department_group()
)
dept_group.add_member(ldap_user.dn)
except Exception:
pass # best effort
except Exception as move_err:
logger.warning(
f"[sync_from_django_to_ldap] Employee "
f"{employee.pk}: "
f"ошибка перемещения: {move_err}"
)
self.message_user(
request,
f"⚠️ {employee.email}: не удалось "
f"переместить в {expected_base}: "
f"{move_err}",
level=messages.WARNING,
)
# Обновляем sync state.
# DN мог измениться после перемещения.
# Перечитываем пользователя, чтобы получить
# актуальный DN.
try:
ldap_user_fresh = _resolve_ldap_user_for_employee(
employee
)
if ldap_user_fresh is None:
raise LdapUser.DoesNotExist
fresh_dn = ldap_user_fresh.dn
except LdapUser.DoesNotExist:
fresh_dn = ldap_user.dn
LdapSyncState.objects.update_or_create(
model="employee",
object_pk=str(employee.pk),
defaults={
"ldap_dn": fresh_dn,
"last_sync_dir": "django",
},
)
if not changes:
success_count += 1 # Уже синхронизирован
except Exception as e:
logger.exception(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: ошибка обновления в LDAP"
)
error_count += 1
self.message_user(
request,
f"Ошибка обновления {employee.email} в LDAP: {e}",
level=messages.ERROR,
)
else:
# СОЗДАНИЕ нового LDAP пользователя
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: LDAP пользователь не найден, создаем новый"
)
try:
# Генерируем временный пароль
import secrets
from employees.ldap.domain.dtos import DirectoryUserDTO
from employees.ldap.utils.phone_utils import (
normalize_phone,
)
from django.conf import settings as _settings
temp_password = secrets.token_urlsafe(16)
phone_e164 = None
if employee.phone_number:
phone_e164 = normalize_phone(employee.phone_number)
# Определяем department_dn на основе отдела и статуса
department_dn = None
if not employee.is_actually_active:
# Уволенный → OU=Dismissed
department_dn = getattr(
_settings, "LDAP_DISMISSED_BASE", None
)
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: "
f"уволен, department_dn={department_dn}"
)
else:
# Активный → OU отдела из EmployeeDepartment
active_dept = employee.departments.first()
if active_dept:
dept_sync = LdapSyncState.objects.filter(
model="department",
object_pk=str(active_dept.pk),
).first()
if dept_sync and dept_sync.ldap_dn:
department_dn = dept_sync.ldap_dn
else:
# Формируем DN отдела из имени
depts_base = getattr(
_settings, "LDAP_DEPARTMENTS_BASE", ""
)
if depts_base:
department_dn = f"OU={
active_dept.name
},{depts_base}"
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: "
f"отдел={active_dept.name}, department_dn={
department_dn
}"
)
dto = DirectoryUserDTO(
first_name=employee.first_name,
last_name=employee.last_name,
email=employee.email,
phone_e164=phone_e164,
department_dn=department_dn,
group_cns=[],
initial_password=temp_password,
)
# Создаем через сервис
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: вызов service.create_user(dto)"
)
service.create_user(dto)
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: LDAP пользователь успешно создан"
)
created_count += 1
employee.is_ldap_managed = True
employee.save(update_fields=["is_ldap_managed"])
logger.info(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: is_ldap_managed установлен в True"
)
self.message_user(
request,
(
f"✅ Создан LDAP пользователь для "
f"{employee.email}. Временный пароль: "
f"{temp_password}"
),
level=messages.SUCCESS,
)
except Exception as e:
logger.exception(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: ошибка создания в LDAP"
)
error_count += 1
self.message_user(
request,
f"Ошибка создания {employee.email} в LDAP: {e}",
level=messages.ERROR,
)
except Exception as e:
logger.exception(
f"[sync_from_django_to_ldap] Employee {
employee.pk
}: необработанное исключение на верхнем уровне"
)
error_count += 1
self.message_user(
request,
f"Ошибка обработки {employee.email}: {e}",
level=messages.ERROR,
)
# Итоговое сообщение
if created_count > 0:
self.message_user(
request,
f"✅ Создано в LDAP: {created_count} пользователей",
level=messages.SUCCESS,
)
if updated_count > 0:
self.message_user(
request,
f"🔄 Обновлено в LDAP: {updated_count} пользователей",
level=messages.SUCCESS,
)
if success_count > 0:
self.message_user(
request,
f"✓ Уже синхронизировано: {success_count} пользователей",
level=messages.INFO,
)
if error_count > 0:
self.message_user(
request, f"❌ Ошибок: {error_count}", level=messages.WARNING
)
@admin.action(
description=(
"🔄 Синхронизировать LDAP → Django "
"(обновить из Active Directory)"
)
)
def sync_from_ldap_to_django(self, request, queryset):
"""Обновляет Django пользователей из LDAP.
LDAP считается источником истины - данные из Active Directory
перезапишут данные в Django Employee.
"""
from employees.ldap.orm_models import LdapUser
from employees.ldap.utils.ldap_utils import get_ldap_str
success_count = 0
error_count = 0
not_in_ldap = 0
for employee in queryset:
if not employee.is_ldap_managed:
not_in_ldap += 1
continue
try:
# Ищем LDAP пользователя
ldap_user = _resolve_ldap_user_for_employee(employee)
if not ldap_user:
# Попробуем по email
ldap_user = LdapUser.objects.filter(
mail=employee.email
).first()
if not ldap_user:
error_count += 1
self.message_user(
request,
f"❌ {employee.email}: не найден в LDAP",
level=messages.WARNING,
)
continue
# Обновляем Django из LDAP
updated_fields = []
ldap_first = get_ldap_str(ldap_user.given_name)
if ldap_first and ldap_first != employee.first_name:
employee.first_name = ldap_first
updated_fields.append("first_name")
ldap_last = get_ldap_str(ldap_user.sn)
if ldap_last and ldap_last != employee.last_name:
employee.last_name = ldap_last
updated_fields.append("last_name")
ldap_email = get_ldap_str(ldap_user.mail)
if ldap_email and ldap_email != employee.email:
employee.email = ldap_email
updated_fields.append("email")
ldap_phone = get_ldap_str(
ldap_user.telephone_number or ldap_user.mobile
)
if ldap_phone and ldap_phone != employee.phone_number:
employee.phone_number = ldap_phone
updated_fields.append("phone_number")
if updated_fields:
employee.save(update_fields=updated_fields)
# Обновляем LdapSyncState
LdapSyncState.objects.update_or_create(
model="employee",
object_pk=str(employee.pk),
defaults={
"ldap_dn": ldap_user.dn,
"ldap_guid": None, # objectGUID удален из модели
"last_sync_dir": "ldap",
},
)
success_count += 1
except Exception as e:
error_count += 1
self.message_user(
request,
f"Ошибка синхронизации {employee.email}: {e}",
level=messages.ERROR,
)
if success_count:
self.message_user(
request,
f"✅ Успешно синхронизировано: {success_count} "
"пользователей (LDAP → Django)",
level=messages.SUCCESS,
)
if not_in_ldap:
self.message_user(
request,
f"ℹ️ Пропущено локальных пользователей: {not_in_ldap}",
level=messages.INFO,
)
if error_count:
self.message_user(
request, f"❌ Ошибок: {error_count}", level=messages.WARNING
)
@admin.action(description="🔍 Показать различия Django ↔ LDAP")
def show_sync_diff(self, request, queryset):
"""Показывает различия между Django и LDAP.
Сравнение выполняется для выбранных пользователей.
"""
from employees.ldap.orm_models import LdapUser
from employees.ldap.utils.ldap_utils import get_ldap_str
diffs = []
no_ldap_count = 0
for employee in queryset:
if not employee.is_ldap_managed:
continue
try:
# Ищем LDAP пользователя
ldap_user = _resolve_ldap_user_for_employee(employee)
if not ldap_user:
ldap_user = LdapUser.objects.filter(
mail=employee.email
).first()
if not ldap_user:
no_ldap_count += 1
diffs.append(f"❌ {employee.email}: не найден в LDAP")
continue
user_diffs = []
# Сравниваем поля
ldap_first = get_ldap_str(ldap_user.given_name)
if ldap_first != employee.first_name:
user_diffs.append(
f"Имя: Django='{employee.first_name}' vs LDAP='{
ldap_first
}'"
)
ldap_last = get_ldap_str(ldap_user.sn)
if ldap_last != employee.last_name:
user_diffs.append(
f"Фамилия: Django='{employee.last_name}' vs LDAP='{
ldap_last
}'"
)
ldap_email = get_ldap_str(ldap_user.mail)
if ldap_email != employee.email:
user_diffs.append(
f"Email: Django='{employee.email}' vs LDAP='{
ldap_email
}'"
)
ldap_phone = get_ldap_str(
ldap_user.telephone_number or ldap_user.mobile
)
if (
employee.phone_number
and ldap_phone
and ldap_phone != employee.phone_number
):
user_diffs.append(
f"Телефон: Django='{employee.phone_number}' vs LDAP='{
ldap_phone
}'"
)
if user_diffs:
diffs.append(
f"⚠️ {employee.email}: " + " | ".join(user_diffs)
)
else:
diffs.append(f"✅ {employee.email}: данные совпадают")
except Exception as e:
diffs.append(f"❌ {employee.email}: ошибка сравнения - {e}")
if diffs:
message = "Результаты сравнения Django ↔ LDAP:\n\n" + "\n".join(
diffs
)
self.message_user(request, message, level=messages.INFO)
else:
self.message_user(
request,
"Выбраны только локальные пользователи (не управляются LDAP)",
level=messages.WARNING,
)
@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",
"ldap_sync_status",
"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", "ldap_sync_info")
inlines = [DepartmentMembershipInline]
fieldsets = (
(None, {"fields": ("name", "description")}),
(
"Руководство",
{
"fields": ("head", "head_appointed_at"),
},
),
(
"LDAP",
{
"fields": ("ldap_group_dn", "ldap_sync_info"),
"classes": ("collapse",),
},
),
("Служебное", {"fields": ("created_at",)}),
)
actions = [
"sync_from_ldap_to_django",
"sync_from_django_to_ldap",
"show_sync_diff",
]
def ldap_sync_status(self, obj):
"""Краткий статус LDAP синхронизации для списка."""
if not obj.pk:
return "-"
from django.utils.html import format_html
from django.urls import reverse
try:
sync_state = LdapSyncState.objects.filter(
model="department", object_pk=str(obj.pk)
).first()
if not sync_state:
# Нет записи синхронизации
create_url = reverse("admin:employees_ldapsyncstate_add")
return format_html(
'<span style="color: red;" '
'title="Нет записи синхронизации">'
'<a href="{}?model=department&object_pk={}" '
'style="color: red;">❌ Нет записи</a>'
"</span>",
create_url,
obj.pk,
)
# Проверяем полноту данных
has_dn = bool(sync_state.ldap_dn)
has_updated = bool(sync_state.updated_at)
# Полные данные
if has_dn and has_updated:
# Определяем направление последней синхронизации
if sync_state.last_sync_dir == "ldap":
icon = "⬇️"
title_dir = "LDAP → Django"
elif sync_state.last_sync_dir == "django":
icon = "⬆️"
title_dir = "Django → LDAP"
else:
icon = "✅"
title_dir = "Синхронизирован"
dn_short = (
sync_state.ldap_dn[:60] + "..."
if len(sync_state.ldap_dn) > 60
else sync_state.ldap_dn
)
return format_html(
'{} <span title="DN: {}\nНаправление: {}">'
"{}</span>",
icon,
dn_short,
title_dir,
sync_state.updated_at.strftime("%d.%m.%Y %H:%M"),
)
# Неполные данные
elif has_updated:
return format_html(
'<span style="color: orange;" title="Отсутствует DN">'
'⚠️ {}</span>',
sync_state.updated_at.strftime("%d.%m.%Y %H:%M"),
)
else:
return format_html(
'<span style="color: orange;" '
'title="Нет даты обновления">'
'⚠️ Неполные данные</span>'
)
except Exception as e:
return format_html(
'<span style="color: red;" title="Ошибка: {}">❌ Ошибка</span>',
str(e),
)
ldap_sync_status.short_description = "LDAP статус"
ldap_sync_status.admin_order_field = "id"
def ldap_sync_info(self, obj):
"""Подробная информация о синхронизации для формы."""
if not obj.pk:
return "-"
from django.utils.html import format_html
from django.urls import reverse
try:
sync_state = LdapSyncState.objects.filter(
model="department", object_pk=str(obj.pk)
).first()
if not sync_state:
create_url = reverse("admin:employees_ldapsyncstate_add")
return format_html(
'<div style="padding:10px;background:#f8d7da;'
'border-left:4px solid #dc3545;">'
"❌ <strong>Нет записи синхронизации</strong><br>"
"<small>Создайте запись синхронизации или выполните "
"синхронизацию.</small><br>"
'<a href="{}?model=department&object_pk={}" '
'target="_blank">➕ Создать запись</a>'
"</div>",
create_url,
obj.pk,
)
# Формируем подробную информацию
sync_url = reverse(
"admin:employees_ldapsyncstate_change", args=[sync_state.pk]
)
if sync_state.last_sync_dir == "ldap":
direction = "⬇️ LDAP → Django"
color = "#17a2b8"
elif sync_state.last_sync_dir == "django":
direction = "⬆️ Django → LDAP"
color = "#0066cc"
else:
direction = "↔️ Неизвестно"
color = "#6c757d"
return format_html(
'<div style="padding:10px;background:#d1ecf1;'
'border-left:4px solid {};">'
"<strong>Информация о синхронизации:</strong><br>"
'<table style="margin-top:5px;">'
'<tr><td><strong>LDAP DN:</strong></td>'
'<td><code style="font-size:0.85em;">{}</code></td></tr>'
"<tr><td><strong>Направление:</strong></td><td>{}</td></tr>"
"<tr><td><strong>Обновлено:</strong></td><td>{}</td></tr>"
"</table>"
'<a href="{}" target="_blank" '
'style="margin-top:10px;display:inline-block;">'
'📋 Открыть запись</a>'
"</div>",
color,
sync_state.ldap_dn or "—",
direction,
sync_state.updated_at.strftime("%Y-%m-%d %H:%M:%S")
if sync_state.updated_at
else "—",
sync_url,
)
except Exception as e:
return format_html(
'<div style="padding:10px;background:#f8d7da;'
'border-left:4px solid #dc3545;">'
"❌ <strong>Ошибка:</strong> {}"
"</div>",
str(e),
)
ldap_sync_info.short_description = "Статус синхронизации"
@admin.action(
description=(
"🔄 Синхронизировать LDAP → Django "
"(LDAP как источник истины)"
)
)
def sync_from_ldap_to_django(self, request, queryset):
"""Синхронизирует выбранные отделы из LDAP OU.
Изменения переносятся в Django Department.
"""
from employees.ldap.orm_models import LdapOrganizationalUnit
from employees.ldap.utils.ldap_utils import get_ldap_str
success_count = 0
error_count = 0
warnings = []
for dept in queryset:
# Ищем соответствующую OU в LDAP
sync_state = LdapSyncState.objects.filter(
model="department",
object_pk=str(dept.pk),
).first()
ldap_ou = None
if sync_state and sync_state.ldap_dn:
try:
ldap_ou = LdapOrganizationalUnit.objects.get(
dn=sync_state.ldap_dn
)
except LdapOrganizationalUnit.DoesNotExist:
warnings.append(
f"⚠️ {dept.name}: OU с DN "
f"{sync_state.ldap_dn} не найдена"
)
if not ldap_ou:
# Пытаемся найти по имени
try:
ldap_ou = LdapOrganizationalUnit.objects.get(ou=dept.name)
except (
LdapOrganizationalUnit.DoesNotExist,
LdapOrganizationalUnit.MultipleObjectsReturned,
):
error_count += 1
warnings.append(
f"⚠️ {dept.name}: не найдена соответствующая OU в LDAP"
)
continue
try:
# Обновляем Department из LDAP OU
dept.description = (
get_ldap_str(ldap_ou.description) or dept.description
)
# Обновляем руководителя из managedBy
mb = get_ldap_str(ldap_ou.managed_by)
if mb:
try:
from employees.ldap.orm_models import LdapUser
mgr = LdapUser.objects.get(dn=mb)
if mgr.employee_number:
try:
dept.head = Employee.objects.get(
pk=int(mgr.employee_number)
)
except (Employee.DoesNotExist, ValueError):
warnings.append(
f"⚠️ {dept.name}: руководитель Employee #{
mgr.employee_number
} не найден"
)
except LdapUser.DoesNotExist:
warnings.append(
f"⚠️ {dept.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"Ошибка синхронизации {dept.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} отделов "
"(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 Department в LDAP OU."""
from employees.ldap.orm_models import LdapOrganizationalUnit
from employees.ldap.infrastructure.connections import _ldap
from django.conf import settings
success_count = 0
error_count = 0
created_count = 0
warnings = []
for dept in queryset:
if not dept.name or not dept.name.strip():
warnings.append(f"⚠️ Department #{dept.pk}: пустое название")
error_count += 1
continue
# Ищем соответствующую OU
sync_state = LdapSyncState.objects.filter(
model="department",
object_pk=str(dept.pk),
).first()
ldap_ou = None
if sync_state and sync_state.ldap_dn:
try:
ldap_ou = LdapOrganizationalUnit.objects.get(
dn=sync_state.ldap_dn
)
except LdapOrganizationalUnit.DoesNotExist:
pass
if not ldap_ou:
# Пытаемся найти по имени
try:
ldap_ou = LdapOrganizationalUnit.objects.get(ou=dept.name)
except LdapOrganizationalUnit.DoesNotExist:
# OU не найдена - создаём через низкоуровневый LDAP
try:
with _ldap() as conn:
base = getattr(
settings, "LDAP_DEPARTMENTS_BASE", ""
)
if not base:
raise RuntimeError(
"LDAP_DEPARTMENTS_BASE not configured"
)
ou_dn = f"OU={dept.name},{base}"
# Проверяем существование
from ldap3 import BASE
ok = conn.search(
ou_dn,
"(objectClass=organizationalUnit)",
search_scope=BASE,
)
if not ok or not conn.entries:
# Создаём OU
ok = conn.add(
ou_dn, ["top", "organizationalUnit"]
)
if not ok:
raise RuntimeError(
f"LDAP add OU failed: {conn.result}"
)
created_count += 1
# Получаем созданную OU через ORM
try:
ldap_ou = LdapOrganizationalUnit.objects.get(
dn=ou_dn
)
except LdapOrganizationalUnit.DoesNotExist:
raise RuntimeError(
f"OU был создан, но не найден по DN: {ou_dn}"
)
except Exception as e:
error_count += 1
self.message_user(
request,
f"Ош ибка создания OU для {dept.name}: {e}",
level=messages.ERROR,
)
continue
except LdapOrganizationalUnit.MultipleObjectsReturned:
error_count += 1
warnings.append(
f"⚠️ {dept.name}: найдено несколько OU с таким именем"
)
continue
try:
# Обновляем LDAP OU из Django Department
ldap_ou.description = dept.description or ""
# Обновляем managedBy
if dept.head:
head_sync = LdapSyncState.objects.filter(
model="employee",
object_pk=str(dept.head.pk),
).first()
if head_sync and head_sync.ldap_dn:
ldap_ou.managed_by = head_sync.ldap_dn
else:
warnings.append(
f"⚠️ {dept.name}: руководитель {dept.head} "
"не имеет LDAP DN"
)
else:
ldap_ou.managed_by = ""
ldap_ou.save()
# Создаём/обновляем группу отдела DEP_*
# и синхронизируем участников
try:
dep_group = ldap_ou.ensure_department_group()
# Собираем DN активных сотрудников отдела
from employees.models import EmployeeDepartment
active_links = EmployeeDepartment.objects.filter(
department=dept,
is_active=True,
).select_related("employee")
member_dns = []
for link in active_links:
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 = dep_group.sync_members(member_dns)
if result["added"] or result["removed"]:
self.message_user(
request,
f"👥 {dept.name}: группа DEP_* — "
f"+{result['added']}/−{result['removed']}",
level=messages.INFO,
)
except Exception as grp_err:
warnings.append(f"⚠️ {dept.name}: группа DEP_*: {grp_err}")
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"Ошибка синхронизации {dept.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 created_count:
self.message_user(
request,
f"Создано новых OU в LDAP: {created_count}",
level=messages.SUCCESS,
)
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="🔍 Показать различия Django ↔ LDAP")
def show_sync_diff(self, request, queryset):
"""Показывает различия между Django Department и LDAP OU."""
from employees.ldap.orm_models import LdapOrganizationalUnit
from employees.ldap.utils.ldap_utils import get_ldap_str
diffs = []
for dept in queryset:
# Ищем OU
sync_state = LdapSyncState.objects.filter(
model="department",
object_pk=str(dept.pk),
).first()
ldap_ou = None
if sync_state and sync_state.ldap_dn:
try:
ldap_ou = LdapOrganizationalUnit.objects.get(
dn=sync_state.ldap_dn
)
except LdapOrganizationalUnit.DoesNotExist:
pass
if not ldap_ou:
try:
ldap_ou = LdapOrganizationalUnit.objects.get(ou=dept.name)
except (
LdapOrganizationalUnit.DoesNotExist,
LdapOrganizationalUnit.MultipleObjectsReturned,
):
diffs.append(
f"{dept.name}: не найдена соответствующая OU в LDAP"
)
continue
dept_diffs = []
# Сравниваем описание
ldap_desc = get_ldap_str(ldap_ou.description) or ""
django_desc = dept.description or ""
if ldap_desc != django_desc:
dept_diffs.append(
"Описание: "
f"LDAP='{ldap_desc[:50]}' vs Django='{django_desc[: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:
dept_diffs.append(
f"Руководитель: LDAP='{mb or '—'}' vs Django='{
dept.head
}'"
)
elif mb:
dept_diffs.append(
f"Руководитель: LDAP='{mb}' vs Django='не назначен'"
)
if dept_diffs:
diffs.append(f"{dept.name}: " + ", ".join(dept_diffs))
if diffs:
self.message_user(
request,
"Найдены различия:\n" + "\n".join(diffs),
level=messages.WARNING,
)
else:
self.message_user(
request,
"Различий не найдено. Все выбранные отделы синхронизированы.",
level=messages.SUCCESS,
)
@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 = ("updated_at",)
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 True
def has_delete_permission(self, request, obj=None):
"""Разрешаем удаление для очистки."""
return True
# =========================
# Кастомный GroupAdmin с LDAP синхронизацией
# =========================
admin.site.unregister(Group)
@admin.register(Group)
class GroupAdmin(DefaultGroupAdmin):
"""Расширенный GroupAdmin с кнопками синхронизации LDAP."""
list_display = ("name", "ldap_dn_display")
actions = ["sync_groups_from_ldap", "sync_groups_to_ldap"]
def ldap_dn_display(self, obj):
sync = LdapSyncState.objects.filter(
model="group",
object_pk=str(obj.pk),
).first()
return sync.ldap_dn if sync else "—"
ldap_dn_display.short_description = "LDAP DN"
# ---------- LDAP → Django ----------
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)"
)
# ---------- Django → LDAP ----------
def sync_groups_to_ldap(self, request, queryset):
"""Создаёт в LDAP группы, которых там ещё нет.
Перед созданием выполняется реальная проверка LDAP.
"""
from employees.ldap.orm_models import LdapGroup
from employees.ldap.services.group_service import GroupService
svc = GroupService()
created = 0
already = 0
for dg in queryset:
# Реальная проверка наличия группы в 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 на случай рассинхрона
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}, уже существовало {already}",
level=messages.SUCCESS,
)
sync_groups_to_ldap.short_description = (
"⬆️ Django → LDAP (создать LDAP Group)"
)