# 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('Локальный') 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( '' '❌ Нет записи (ID: {})' "", 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( '✅ {}', 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( '⚠️ {}', obj.pk, ", ".join(missing), sync_state.updated_at.strftime("%d.%m.%Y %H:%M"), ) else: return format_html( '' '⚠️ Неполные данные', obj.pk, ) except Exception as e: logger.exception( f"[ldap_sync_status] Error for employee {obj.pk}: {e}" ) return format_html( '❌ Ошибка', 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( '' '📋 Открыть запись синхронизации', sync_url, ) ] if sync_state.ldap_dn: info.append(f"DN: {sync_state.ldap_dn}") if sync_state.ldap_guid: info.append(f"GUID: {sync_state.ldap_guid}") if sync_state.last_sync_dir: info.append(f"Направление: {sync_state.last_sync_dir}") if sync_state.last_ldap_modify_ts: info.append( f"LDAP изменен: {sync_state.last_ldap_modify_ts}" ) if sync_state.last_django_modify_ts: info.append( ( "Django изменен: " f"{sync_state.last_django_modify_ts}" ) ) if sync_state.updated_at: info.append( f"Обновлено: {sync_state.updated_at}" ) return format_html("
".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( '' '❌ Нет записи' "", 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( '{} ' "{}", icon, dn_short, title_dir, sync_state.updated_at.strftime("%d.%m.%Y %H:%M"), ) # Неполные данные elif has_updated: return format_html( '' '⚠️ {}', sync_state.updated_at.strftime("%d.%m.%Y %H:%M"), ) else: return format_html( '' '⚠️ Неполные данные' ) except Exception as e: return format_html( '❌ Ошибка', 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( '
' "❌ Нет записи синхронизации
" "Создайте запись синхронизации или выполните " "синхронизацию.
" '➕ Создать запись' "
", 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( '
' "Информация о синхронизации:
" '' '' '' "" "" "
LDAP DN:{}
Направление:{}
Обновлено:{}
" '' '📋 Открыть запись' "
", 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( '
' "❌ Ошибка: {}" "
", 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)" )