diff --git a/backend/employees/admin.py b/backend/employees/admin.py index 989c6347..5d9e5474 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -577,46 +577,110 @@ class EmployeeAdmin(DjangoUserAdmin): changes = {} ldap_first = get_ldap_str(ldap_user.given_name) - logger.debug(f"[sync_from_django_to_ldap] Employee {employee.pk}: сравнение given_name: LDAP='{ldap_first}' vs Django='{employee.first_name}'") if ldap_first != employee.first_name: changes['first_name'] = employee.first_name ldap_last = get_ldap_str(ldap_user.sn) - logger.debug(f"[sync_from_django_to_ldap] Employee {employee.pk}: сравнение sn: LDAP='{ldap_last}' vs Django='{employee.last_name}'") if ldap_last != employee.last_name: changes['last_name'] = employee.last_name ldap_email = get_ldap_str(ldap_user.mail) - logger.debug(f"[sync_from_django_to_ldap] Employee {employee.pk}: сравнение mail: LDAP='{ldap_email}' vs Django='{employee.email}'") if ldap_email != employee.email: changes['email'] = employee.email ldap_phone = get_ldap_str(ldap_user.telephone_number or ldap_user.mobile) - logger.debug(f"[sync_from_django_to_ldap] Employee {employee.pk}: сравнение phone: LDAP='{ldap_phone}' vs Django='{employee.phone_number}'") if employee.phone_number and ldap_phone != employee.phone_number: changes['phone_number'] = employee.phone_number - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: обнаружено изменений: {len(changes)} -> {list(changes.keys())}") - if changes: - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: вызов service.update_user с изменениями: {changes}") service.update_user(employee, changes) - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: успешно обновлен в LDAP") updated_count += 1 - - # Обновляем sync state - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: обновление LdapSyncState") - LdapSyncState.objects.update_or_create( - model='employee', - object_pk=str(employee.pk), - defaults={ - 'ldap_dn': ldap_user.dn, - 'last_sync_dir': 'django', - } + + # Проверяем, в правильной ли 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 ) - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: LdapSyncState обновлен") else: - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: изменений не обнаружено, уже синхронизирован") + 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},{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}: перемещён в {expected_base}', + level=messages.INFO + ) + + # Добавляем в группу DEP_* нового отдела + try: + from employees.ldap.orm_models import LdapOrganizationalUnit + ou = LdapOrganizationalUnit.objects.get( + dn=expected_base + ) + ou.ensure_department_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 {employee.pk}: " + f"ошибка перемещения: {move_err}" + ) + self.message_user( + request, + f'⚠️ {employee.email}: не удалось переместить в {expected_base}: {move_err}', + level=messages.WARNING + ) + + # Обновляем sync state (DN мог измениться после перемещения) + # Перечитываем пользователя чтобы получить актуальный DN + try: + ldap_user_fresh = LdapUser.objects.get( + employee_number=str(employee.pk) + ) + 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") @@ -634,26 +698,57 @@ class EmployeeAdmin(DjangoUserAdmin): 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) - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: временный пароль сгенерирован") phone_e164 = None if employee.phone_number: phone_e164 = normalize_phone(employee.phone_number) - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: телефон нормализован: {phone_e164}") - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: создание DirectoryUserDTO") + # Определяем 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=None, + department_dn=department_dn, group_cns=[], initial_password=temp_password, ) - logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: DTO создан: {dto}") # Создаем через сервис logger.info(f"[sync_from_django_to_ldap] Employee {employee.pk}: вызов service.create_user(dto)") @@ -922,11 +1017,11 @@ class EmployeeActionAdmin(SimpleHistoryAdmin): @admin.register(Department) class DepartmentAdmin(admin.ModelAdmin): - list_display = ("name", "head", "head_appointed_at", "created_at") + 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") + readonly_fields = ("created_at", "head_appointed_at", "ldap_sync_info") inlines = [DepartmentMembershipInline] fieldsets = ( @@ -935,11 +1030,487 @@ class DepartmentAdmin(admin.ModelAdmin): "fields": ("head", "head_appointed_at"), }), ("LDAP", { - "fields": ("ldap_group_dn",), + "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 {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 + created_new = False + + 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_new = True + 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) diff --git a/backend/employees/apps.py b/backend/employees/apps.py index f54cdb6f..bce48e16 100644 --- a/backend/employees/apps.py +++ b/backend/employees/apps.py @@ -31,12 +31,17 @@ class EmployeesConfig(AppConfig): def safe_results_iter(self, results=None, tuple_expected=False, chunked_fetch=False, chunk_size=None): """Обёртка над оригинальным results_iter с безопасным attname.""" + import ldap as ldap_lib try: yield from original_results_iter( self, results=results, tuple_expected=tuple_expected, chunked_fetch=chunked_fetch, **({"chunk_size": chunk_size} if chunk_size is not None else {}), ) + except ldap_lib.NO_SUCH_OBJECT: + # DN не существует — возвращаем пустой результат + # (django-ldapdb бросает ldap.NO_SUCH_OBJECT вместо DoesNotExist) + return except AttributeError as exc: if "attname" not in str(exc): raise diff --git a/backend/employees/ldap/__init__.py b/backend/employees/ldap/__init__.py index a99ab216..8ea0c412 100644 --- a/backend/employees/ldap/__init__.py +++ b/backend/employees/ldap/__init__.py @@ -72,7 +72,12 @@ from .domain.dtos import ( from .utils.group_utils_orm import sync_user_groups_by_cns_orm # ORM модели -from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit +from .orm_models import ( + LdapUser, + LdapGroup, + LdapOrganizationalUnitGroup, + LdapOrganizationalUnit, +) # Подключения from .infrastructure.connections import LdapConfig, _ldap @@ -119,6 +124,7 @@ __all__ = [ # ORM "LdapUser", "LdapGroup", + "LdapOrganizationalUnitGroup", "LdapOrganizationalUnit", # Подключения diff --git a/backend/employees/ldap/admin.py b/backend/employees/ldap/admin.py index cdb42703..897d7d99 100644 --- a/backend/employees/ldap/admin.py +++ b/backend/employees/ldap/admin.py @@ -7,14 +7,20 @@ """ import base64 +from urllib.parse import quote, unquote from django.contrib import admin, messages from django.utils.html import format_html -from django.urls import reverse +from django.urls import reverse, path from django.db.models import OuterRef, Exists from employees.models import Employee, LdapSyncState -from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit +from .orm_models import ( + LdapUser, + LdapGroup, + LdapOrganizationalUnitGroup, + LdapOrganizationalUnit, +) from .utils.ldap_utils import get_ldap_str @@ -48,6 +54,12 @@ class LdapUserAdmin(admin.ModelAdmin): - Actions для синхронизации с выбором источника истины """ + def get_urls(self): + """Переопределяем URLs для корректной работы с DN как PK.""" + urls = super().get_urls() + # Django автоматически добавит наши URL patterns с правильным кодированием + return urls + list_display = ( 'cn_display', 'sam_account_name', @@ -631,4 +643,1153 @@ class LdapUserAdmin(admin.ModelAdmin): return super().get_queryset(request) -# TODO: Добавить LdapGroupAdmin и LdapOrganizationalUnitAdmin аналогично +@admin.register(LdapOrganizationalUnit) +class LdapOrganizationalUnitAdmin(admin.ModelAdmin): + """Админка для LDAP Organizational Units (отделов). + + Возможности: + - Просмотр всех OU в Active Directory + - Ручное редактирование (описание, managedBy) + - Связь с Django Department + - Actions для синхронизации и удаления + """ + + def get_urls(self): + """Переопределяем URLs для корректной работы с DN как PK.""" + urls = super().get_urls() + return urls + + list_display = ( + 'ou_display', + 'description_short', + 'managed_by_display', + 'django_department_link', + 'when_changed', + ) + + search_fields = ( + 'ou', + 'description', + 'managed_by', + ) + + readonly_fields = ( + 'dn_display', + 'managed_by_link', + 'django_department_info', + ) + + fieldsets = ( + ('🔑 Идентификация', { + 'fields': ( + 'dn_display', + 'ou', + ) + }), + ('📝 Описание', { + 'fields': ( + 'description', + ) + }), + ('👤 Руководитель (managedBy)', { + 'fields': ( + 'managed_by', + 'managed_by_link', + ) + }), + ('🔗 Связь с Django', { + 'fields': ( + 'django_department_info', + ) + }), + ) + + actions = [ + 'delete_selected_ldap', + 'sync_from_ldap_to_django', + 'sync_from_django_to_ldap', + 'show_sync_diff', + ] + + list_per_page = 100 + + def _resolve_ldap_ous(self, request): + """Получает выбранные LDAP OU по DN из POST данных. + + ldapdb не поддерживает dn__in lookup, поэтому + получаем каждый объект по DN отдельно. + """ + selected_dns = request.POST.getlist('_selected_action') + ous = [] + for dn in selected_dns: + try: + ou = LdapOrganizationalUnit.objects.get(dn=dn) + ous.append(ou) + except LdapOrganizationalUnit.DoesNotExist: + pass + return ous + + def _find_django_department(self, ldap_ou): + """Ищет Django Department по имени OU или по ldap_dn в SyncState.""" + from employees.models import Department + # Сначала пробуем по LdapSyncState + sync = LdapSyncState.objects.filter( + model='department', + ldap_dn=ldap_ou.dn, + ).first() + if sync: + try: + return Department.objects.get(pk=int(sync.object_pk)) + except (Department.DoesNotExist, ValueError): + pass + # Fallback: по совпадению имени + try: + return Department.objects.get(name=ldap_ou.ou) + except Department.DoesNotExist: + return None + + # --- Кастомные поля для отображения --- + + def ou_display(self, obj): + """Имя OU с иконкой.""" + return format_html('🏢 {}', obj.ou) if obj.ou else '-' + ou_display.short_description = 'OU (Отдел)' + ou_display.admin_order_field = 'ou' + + def description_short(self, obj): + """Сокращённое описание.""" + desc = get_ldap_str(obj.description) or '' + if len(desc) > 80: + return desc[:80] + '…' + return desc or format_html('—') + description_short.short_description = 'Описание' + + def dn_display(self, obj): + """DN с подсветкой компонентов.""" + if not obj.dn: + return '-' + parts = obj.dn.split(',') + html_parts = [] + for part in parts: + if part.startswith('OU='): + html_parts.append(format_html( + '{}', part, + )) + elif part.startswith('DC='): + html_parts.append(format_html( + '{}', part, + )) + else: + html_parts.append(part) + return format_html(','.join(html_parts)) + dn_display.short_description = 'Distinguished Name' + + def managed_by_display(self, obj): + """Сокращённое отображение managedBy в списке.""" + mb = get_ldap_str(obj.managed_by) + if not mb: + return format_html('—') + # Извлекаем CN из DN + cn = mb.split(',')[0].replace('CN=', '') + return format_html('👤 {}', cn) + managed_by_display.short_description = 'Руководитель' + + def managed_by_link(self, obj): + """Подробная информация о руководителе с ссылкой.""" + mb = get_ldap_str(obj.managed_by) + if not mb: + return format_html( + 'Не назначен' + ) + # Пробуем найти LdapUser по DN + try: + ldap_user = LdapUser.objects.get(dn=mb) + # Правильный URL pattern для LDAP моделей + url = reverse( + 'admin:ldap_ldapuser_change', args=[quote(ldap_user.dn, safe='')], + ) + return format_html( + '👤 {} ({})
' + '{}', + url, ldap_user.display_name or ldap_user.cn, + ldap_user.sam_account_name or '', mb, + ) + except LdapUser.DoesNotExist: + return format_html( + '{}', mb, + ) + managed_by_link.short_description = 'Руководитель (подробно)' + + def django_department_link(self, obj): + """Ссылка на Django Department в списке.""" + from employees.models import Department + dept = self._find_django_department(obj) + if dept: + url = reverse('admin:employees_department_change', args=[dept.pk]) + return format_html( + '🏢 {}', url, dept.name, + ) + return format_html('—') + django_department_link.short_description = 'Django Department' + + def django_department_info(self, obj): + """Подробная информация о связи с Django Department.""" + from employees.models import Department + dept = self._find_django_department(obj) + if not dept: + return format_html( + '
' + '⚠️ Не связан с Django Department
' + 'Этот LDAP OU не привязан к записи в БД.' + '
' + ) + sync_state = LdapSyncState.objects.filter( + model='department', + object_pk=str(dept.pk), + ).first() + return format_html( + '
' + '✅ Связан с Django Department
' + '' + '' + '' + '' + '' + '
Department ID:{}
Название:{}
Руководитель:{}
Последняя синхронизация:{}
', + dept.pk, + dept.name, + dept.head or '—', + sync_state.updated_at.strftime('%Y-%m-%d %H:%M:%S') if sync_state else '—', + ) + django_department_info.short_description = 'Связь с Django' + + # --- Actions --- + + @admin.action(description='🔄 Синхронизировать LDAP → Django (LDAP как источник истины)') + def sync_from_ldap_to_django(self, request, queryset): + """Синхронизирует выбранные OU из LDAP в Django Department.""" + from employees.models import Department + + success_count = 0 + error_count = 0 + warnings = [] + + ldap_ous = self._resolve_ldap_ous(request) + for ldap_ou in ldap_ous: + ou_name = get_ldap_str(ldap_ou.ou) + if not ou_name: + error_count += 1 + warnings.append(f'⚠️ {ldap_ou.dn}: пустое имя OU') + continue + + try: + dept = self._find_django_department(ldap_ou) + if not dept: + # Создаём Department + dept = Department(name=ou_name) + + dept.description = get_ldap_str(ldap_ou.description) or dept.description + + # Пытаемся найти руководителя + mb = get_ldap_str(ldap_ou.managed_by) + if mb: + try: + mgr = LdapUser.objects.get(dn=mb) + if mgr.employee_number: + from employees.models import Employee + try: + dept.head = Employee.objects.get( + pk=int(mgr.employee_number), + ) + except (Employee.DoesNotExist, ValueError): + warnings.append( + f'⚠️ {ou_name}: руководитель Employee #{mgr.employee_number} не найден', + ) + except LdapUser.DoesNotExist: + warnings.append( + f'⚠️ {ou_name}: managedBy DN не найден в LDAP', + ) + + dept._skip_ldap_sync = True + dept.save() + + LdapSyncState.objects.update_or_create( + model='department', + object_pk=str(dept.pk), + defaults={ + 'ldap_dn': ldap_ou.dn, + 'last_sync_dir': 'ldap', + }, + ) + success_count += 1 + except Exception as e: + error_count += 1 + self.message_user( + request, f'Ошибка синхронизации {ou_name}: {e}', + level=messages.ERROR, + ) + + for w in warnings[:10]: + self.message_user(request, w, level=messages.WARNING) + if len(warnings) > 10: + self.message_user( + request, f'...и ещё {len(warnings) - 10} предупреждений', + level=messages.WARNING, + ) + if success_count: + self.message_user( + request, + f'Успешно синхронизировано: {success_count} OU (LDAP → Django)', + level=messages.SUCCESS, + ) + if error_count: + self.message_user( + request, f'Ошибок: {error_count}', level=messages.WARNING, + ) + + @admin.action(description='🔄 Синхронизировать Django → LDAP (Django как источник истины)') + def sync_from_django_to_ldap(self, request, queryset): + """Синхронизирует выбранные OU из Django Department в LDAP.""" + from employees.models import Department + + success_count = 0 + error_count = 0 + warnings = [] + + ldap_ous = self._resolve_ldap_ous(request) + for ldap_ou in ldap_ous: + dept = self._find_django_department(ldap_ou) + if not dept: + error_count += 1 + warnings.append( + f'⚠️ {ldap_ou.dn}: не найден связанный Django Department', + ) + continue + + missing_fields = [] + if not dept.name or not dept.name.strip(): + missing_fields.append('name (название)') + if missing_fields: + warnings.append( + f'⚠️ Department #{dept.pk}: отсутствуют поля: {", ".join(missing_fields)}', + ) + + try: + ldap_ou.description = dept.description or '' + + # Обновляем managedBy + if dept.head: + sync = LdapSyncState.objects.filter( + model='employee', + object_pk=str(dept.head.pk), + ).first() + if sync and sync.ldap_dn: + ldap_ou.managed_by = sync.ldap_dn + else: + warnings.append( + f'⚠️ {dept.name}: руководитель не имеет LDAP DN', + ) + else: + ldap_ou.managed_by = '' + + ldap_ou.save() + + LdapSyncState.objects.update_or_create( + model='department', + object_pk=str(dept.pk), + defaults={ + 'ldap_dn': ldap_ou.dn, + 'last_sync_dir': 'django', + }, + ) + success_count += 1 + except Exception as e: + error_count += 1 + self.message_user( + request, f'Ошибка синхронизации {ldap_ou.ou}: {e}', + level=messages.ERROR, + ) + + for w in warnings[:10]: + self.message_user(request, w, level=messages.WARNING) + if len(warnings) > 10: + self.message_user( + request, f'...и ещё {len(warnings) - 10} предупреждений', + level=messages.WARNING, + ) + if success_count: + self.message_user( + request, + f'Успешно синхронизировано: {success_count} OU (Django → LDAP)', + level=messages.SUCCESS, + ) + if error_count: + self.message_user( + request, f'Ошибок: {error_count}', level=messages.WARNING, + ) + + @admin.action(description='🔍 Показать различия LDAP ↔ Django') + def show_sync_diff(self, request, queryset): + """Показывает различия между LDAP OU и Django Department.""" + from employees.models import Department + diffs = [] + + ldap_ous = self._resolve_ldap_ous(request) + for ldap_ou in ldap_ous: + dept = self._find_django_department(ldap_ou) + if not dept: + diffs.append(f'{ldap_ou.ou}: не найден связанный Django Department') + continue + + ou_diffs = [] + ldap_desc = get_ldap_str(ldap_ou.description) or '' + if ldap_desc != (dept.description or ''): + ou_diffs.append( + f"Описание: LDAP='{ldap_desc[:50]}' vs Django='{(dept.description or '')[:50]}'" + ) + + # Сравниваем руководителя + mb = get_ldap_str(ldap_ou.managed_by) + if dept.head: + head_sync = LdapSyncState.objects.filter( + model='employee', + object_pk=str(dept.head.pk), + ).first() + head_dn = head_sync.ldap_dn if head_sync else None + if mb != head_dn: + ou_diffs.append( + f"Руководитель: LDAP='{mb or '—'}' vs Django='{dept.head}'" + ) + elif mb: + ou_diffs.append(f"Руководитель: LDAP='{mb}' vs Django='не назначен'") + + if ou_diffs: + diffs.append(f'{ldap_ou.ou}: ' + ', '.join(ou_diffs)) + + if diffs: + self.message_user( + request, + 'Найдены различия:\n' + '\n'.join(diffs), + level=messages.WARNING, + ) + else: + self.message_user( + request, + 'Различий не найдено. Все выбранные OU синхронизированы.', + level=messages.SUCCESS, + ) + + # --- Permissions & overrides --- + + def has_add_permission(self, request): + """Запрещаем создание OU через админку.""" + return False + + def has_delete_permission(self, request, obj=None): + """Удаление только суперпользователям.""" + return request.user.is_superuser + + def get_action_choices(self, request, default_choices=None): + """Убираем стандартный delete_selected (ldapdb не поддерживает dn__in).""" + if default_choices is None: + from django.db import models + default_choices = models.BLANK_CHOICE_DASH + choices = super().get_action_choices(request, default_choices) + return [ + c for c in choices + if not (isinstance(c[0], str) and c[0] == 'delete_selected') + ] + + @admin.action(description='🗑️ Удалить выбранные OU из LDAP') + def delete_selected_ldap(self, request, queryset): + """Кастомное удаление — обходит dn__in, читает DN из POST.""" + ous = self._resolve_ldap_ous(request) + if not ous: + self.message_user( + request, 'Не удалось найти выбранные OU.', + level=messages.WARNING, + ) + return + + if request.POST.get('post') != 'yes': + from django.template.response import TemplateResponse + context = { + **self.admin_site.each_context(request), + 'title': 'Подтверждение удаления', + 'objects_name': 'LDAP Organizational Units', + 'deletable_objects': [str(o) for o in ous], + 'queryset': ous, + 'opts': self.model._meta, + 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, + 'media': self.media, + } + request.current_app = self.admin_site.name + return TemplateResponse( + request, + 'admin/ldap_delete_confirmation.html', + context, + ) + + deleted = 0 + for ou_obj in ous: + try: + ou_obj.delete() + deleted += 1 + except Exception as e: + self.message_user( + request, f'Ошибка удаления {ou_obj.ou}: {e}', + level=messages.ERROR, + ) + if deleted: + self.message_user( + request, f'Удалено: {deleted} OU из LDAP.', + level=messages.SUCCESS, + ) + + def get_queryset(self, request): + """Базовый queryset.""" + return super().get_queryset(request) + + def changelist_view(self, request, extra_context=None): + """Переопределяем для фильтрации корневого контейнера.""" + from .orm_models import get_departments_base + + # Сохраняем base DN для использования в get_results + self._exclude_base_dn = get_departments_base().lower() + + return super().changelist_view(request, extra_context) + + def get_changelist_instance(self, request): + """Создаём кастомный changelist с фильтрацией.""" + from django.contrib.admin.views.main import ChangeList + + class FilteredChangeList(ChangeList): + """ChangeList с фильтрацией base DN.""" + + def get_results(self, request): + super().get_results(request) + + # Фильтруем результаты после получения + if hasattr(self.model_admin, '_exclude_base_dn'): + exclude = self.model_admin._exclude_base_dn + self.result_list = [ + obj for obj in self.result_list + if obj.dn and obj.dn.lower() != exclude + ] + self.result_count = len(self.result_list) + self.full_result_count = self.result_count + + list_display = self.get_list_display(request) + list_display_links = self.get_list_display_links(request, list_display) + list_filter = self.get_list_filter(request) + search_fields = self.get_search_fields(request) + list_select_related = self.get_list_select_related(request) + + return FilteredChangeList( + request, + self.model, + list_display, + list_display_links, + list_filter, + self.date_hierarchy, + search_fields, + list_select_related, + self.list_per_page, + self.list_max_show_all, + self.list_editable, + self, + self.sortable_by, + self.search_help_text, + ) + + +@admin.register(LdapOrganizationalUnitGroup) +class LdapOrganizationalUnitGroupAdmin(admin.ModelAdmin): + """Админка для LDAP групп OU (DEP_*). + + Эти группы содержат всех активных сотрудников отделов. + Автоматически создаются и синхронизируются при + синхронизации Department → LDAP. + + Возможности: + - Просмотр состава группы + - Связь с Django Department и OU + - Просмотр членов группы + """ + + def get_urls(self): + """Переопределяем URLs для корректной работы с DN как PK.""" + urls = super().get_urls() + return urls + + list_display = ( + 'group_display', + 'description_short', + 'members_count', + 'ou_link', + 'django_department_link', + 'when_changed', + ) + + search_fields = ( + 'cn', + 'description', + 'sam_account_name', + ) + + readonly_fields = ( + 'dn_display', + 'cn', + 'sam_account_name', + 'members_list', + 'ou_info', + 'django_department_info', + ) + + fieldsets = ( + ('🔑 Идентификация', { + 'fields': ( + 'dn_display', + 'cn', + 'sam_account_name', + ) + }), + ('📝 Описание', { + 'fields': ( + 'description', + ) + }), + ('👥 Состав группы', { + 'fields': ( + 'members_list', + ) + }), + ('🏢 Связь с OU', { + 'fields': ( + 'ou_info', + ) + }), + ('🔗 Связь с Django', { + 'fields': ( + 'django_department_info', + ) + }), + ) + + list_per_page = 100 + + def has_add_permission(self, request): + """Группы создаются автоматически через OU.""" + return False + + def has_delete_permission(self, request, obj=None): + """Удаление через LDAP OU admin.""" + return False + + def _find_django_department(self, ou_group): + """Находит Department по имени группы DEP_*.""" + from employees.models import Department + # Извлекаем имя отдела из CN группы + cn = get_ldap_str(ou_group.cn) or '' + if cn.startswith('DEP_'): + dept_name = cn[4:] # Убираем префикс DEP_ + try: + return Department.objects.get(name=dept_name) + except Department.DoesNotExist: + pass + return None + + def _find_ldap_ou(self, ou_group): + """Находит LdapOrganizationalUnit по DN группы.""" + # DN группы: CN=DEP_IT,OU=IT,OU=Departments,... + # OU DN: OU=IT,OU=Departments,... + dn_parts = ou_group.dn.split(',', 1) + if len(dn_parts) == 2: + ou_dn = dn_parts[1] + try: + return LdapOrganizationalUnit.objects.get(dn=ou_dn) + except LdapOrganizationalUnit.DoesNotExist: + pass + return None + + # --- Кастомные поля для отображения --- + + def group_display(self, obj): + """Имя группы с иконкой.""" + cn = get_ldap_str(obj.cn) or '' + return format_html('👥 {}', cn) + group_display.short_description = 'Группа OU' + group_display.admin_order_field = 'cn' + + def description_short(self, obj): + """Сокращённое описание.""" + desc = get_ldap_str(obj.description) or '' + if len(desc) > 60: + return desc[:60] + '…' + return desc or format_html('—') + description_short.short_description = 'Описание' + + def members_count(self, obj): + """Количество участников.""" + members = obj.member or [] + count = len(members) + if count == 0: + return format_html( + '0' + ) + return format_html( + '{}', count, + ) + members_count.short_description = 'Участников' + + def dn_display(self, obj): + """DN с подсветкой.""" + if not obj.dn: + return '-' + parts = obj.dn.split(',') + html_parts = [] + for part in parts: + if part.startswith('CN='): + html_parts.append(format_html( + '{}', part, + )) + elif part.startswith('OU='): + html_parts.append(format_html( + '{}', part, + )) + elif part.startswith('DC='): + html_parts.append(format_html( + '{}', part, + )) + else: + html_parts.append(part) + return format_html(','.join(html_parts)) + dn_display.short_description = 'Distinguished Name' + + def members_list(self, obj): + """Список участников группы с ссылками.""" + members = obj.member or [] + if not members: + return format_html( + 'Нет участников' + ) + + html = '
' + html += '
    ' + + for member_dn in members[:50]: # Первые 50 + try: + ldap_user = LdapUser.objects.get(dn=member_dn) + url = reverse( + 'admin:employees_ldapuser_change', + args=[ldap_user.dn], + ) + html += format_html( + '
  1. {} ({})
  2. ', + url, + ldap_user.display_name or ldap_user.cn or '?', + ldap_user.sam_account_name or '', + ) + except LdapUser.DoesNotExist: + html += format_html( + '
  3. {} ' + '(не найден)
  4. ', + member_dn, + ) + + if len(members) > 50: + html += format_html( + '
  5. ...и ещё {} участников
  6. ', + len(members) - 50, + ) + + html += '
' + return format_html(html) + members_list.short_description = 'Участники группы' + + def ou_link(self, obj): + """Ссылка на родительскую OU.""" + ldap_ou = self._find_ldap_ou(obj) + if ldap_ou: + url = reverse( + 'admin:employees_ldaporganizationalunit_change', + args=[ldap_ou.dn], + ) + return format_html( + '🏢 {}', + url, ldap_ou.ou, + ) + return format_html('—') + ou_link.short_description = 'OU' + + def ou_info(self, obj): + """Информация о родительской OU.""" + ldap_ou = self._find_ldap_ou(obj) + if not ldap_ou: + return format_html( + '
' + '❌ OU не найдена
' + 'Родительская Organizational Unit не существует ' + 'или недоступна.' + '
' + ) + + url = reverse( + 'admin:employees_ldaporganizationalunit_change', + args=[ldap_ou.dn], + ) + return format_html( + '
' + '✅ Связана с OU
' + '' + '' + '' + '' + '' + '
OU:{}
DN:{}
', + url, ldap_ou.ou, ldap_ou.dn, + ) + ou_info.short_description = 'Информация об OU' + + def django_department_link(self, obj): + """Ссылка на Django Department.""" + dept = self._find_django_department(obj) + if dept: + url = reverse( + 'admin:employees_department_change', + args=[dept.pk], + ) + return format_html( + '🏢 {}', + url, dept.name, + ) + return format_html('—') + django_department_link.short_description = 'Django Department' + + def django_department_info(self, obj): + """Информация о Django Department.""" + dept = self._find_django_department(obj) + if not dept: + return format_html( + '
' + '⚠️ Department не найден
' + 'Эта группа не связана с отделом в БД.' + '
' + ) + + url = reverse( + 'admin:employees_department_change', args=[dept.pk], + ) + return format_html( + '
' + '✅ Связана с Department
' + '' + '' + '' + '' + '' + '' + '
ID:{}
Название:{}
Руководитель:{}
', + dept.pk, url, dept.name, dept.head or '—', + ) + django_department_info.short_description = 'Связь с Django' + + +@admin.register(LdapGroup) +class LdapGroupAdmin(admin.ModelAdmin): + """Админка для LDAP групп (глобальные + роли отделов). + + Охватывает: + - Глобальные группы: CN=GroupName,OU=Groups,... + - Роли отделов: CN=ROLE_*,OU=,OU=Departments,... + + НЕ охватывает группы отделов DEP_* — для них LdapOrganizationalUnitGroupAdmin. + + Возможности: + - Просмотр состава группы + - Управление членством + - Просмотр вложенности групп + """ + + def get_urls(self): + """Переопределяем URLs для корректной работы с DN как PK.""" + urls = super().get_urls() + return urls + + list_display = ( + 'group_display', + 'sam_account_name', + 'description_short', + 'group_type_display', + 'members_count', + 'when_changed', + ) + + list_filter = ( + ('cn', admin.EmptyFieldListFilter), + ) + + search_fields = ( + 'cn', + 'sam_account_name', + 'description', + ) + + readonly_fields = ( + 'dn_display', + 'cn', + 'sam_account_name', + 'when_created', + 'when_changed', + 'members_list', + 'member_of_list', + ) + + fieldsets = ( + ('🔑 Идентификация', { + 'fields': ( + 'dn_display', + 'cn', + 'sam_account_name', + ) + }), + ('📝 Описание', { + 'fields': ( + 'description', + ) + }), + ('👥 Участники группы', { + 'fields': ( + 'members_list', + ) + }), + ('📊 Членство в группах', { + 'classes': ('collapse',), + 'fields': ( + 'member_of_list', + ) + }), + ('📅 Временные метки', { + 'classes': ('collapse',), + 'fields': ( + 'when_created', + 'when_changed', + ) + }), + ) + + list_per_page = 100 + + def has_add_permission(self, request): + """Группы создаются через GroupService или автоматически.""" + return False + + def has_delete_permission(self, request, obj=None): + """Удаление через LDAP services.""" + return False + + # --- Кастомные поля для отображения --- + + def group_display(self, obj): + """Имя группы с иконкой.""" + cn = get_ldap_str(obj.cn) or '' + + # Определяем тип по DN + if 'OU=Groups' in obj.dn: + icon = '🌐' # Глобальная + elif cn.startswith('ROLE_'): + icon = '🎭' # Роль + elif cn.startswith('DEP_'): + icon = '🏢' # Отдел (хотя не должно быть здесь) + else: + icon = '👥' + + return format_html('{} {}', icon, cn) + group_display.short_description = 'Группа' + group_display.admin_order_field = 'cn' + + def group_type_display(self, obj): + """Тип группы по расположению.""" + cn = get_ldap_str(obj.cn) or '' + dn = obj.dn or '' + + if 'OU=Groups' in dn: + return format_html( + '🌐 Глобальная' + ) + elif cn.startswith('ROLE_'): + # Извлекаем название отдела из DN + parts = dn.split(',') + dept = None + for i, part in enumerate(parts): + if part.startswith('OU=') and i > 0: + dept = part[3:] + break + if dept: + return format_html( + '🎭 Роль ({})', + dept + ) + return format_html( + '🎭 Роль' + ) + elif cn.startswith('DEP_'): + return format_html( + '🏢 Группа отдела' + ) + else: + return format_html( + '👥 Группа' + ) + group_type_display.short_description = 'Тип' + + def description_short(self, obj): + """Сокращённое описание.""" + desc = get_ldap_str(obj.description) or '' + if len(desc) > 60: + return desc[:60] + '…' + return desc or format_html('—') + description_short.short_description = 'Описание' + + def members_count(self, obj): + """Количество участников.""" + members = obj.member or [] + count = len(members) + if count == 0: + return format_html( + '0' + ) + return format_html( + '{}', count, + ) + members_count.short_description = 'Участников' + + def dn_display(self, obj): + """DN с подсветкой.""" + if not obj.dn: + return '-' + parts = obj.dn.split(',') + html_parts = [] + for part in parts: + if part.startswith('CN='): + html_parts.append(format_html( + '{}', part, + )) + elif part.startswith('OU='): + html_parts.append(format_html( + '{}', part, + )) + elif part.startswith('DC='): + html_parts.append(format_html( + '{}', part, + )) + else: + html_parts.append(part) + return format_html(','.join(html_parts)) + dn_display.short_description = 'Distinguished Name' + + def members_list(self, obj): + """Список участников группы с ссылками.""" + members = obj.member or [] + if not members: + return format_html( + 'Нет участников' + ) + + html = '
' + html += '
    ' + + for member_dn in members[:100]: # Первые 100 + # Определяем тип участника + if member_dn.startswith('CN=') and ',OU=' in member_dn: + # Пользователь + try: + ldap_user = LdapUser.objects.get(dn=member_dn) + url = reverse( + 'admin:ldap_ldapuser_change', + args=[quote(ldap_user.dn, safe='')], + ) + html += format_html( + '
  1. 👤 {} ({})
  2. ', + url, + ldap_user.display_name or ldap_user.cn or '?', + ldap_user.sam_account_name or '', + ) + except LdapUser.DoesNotExist: + # Может быть группа + try: + ldap_group = LdapGroup.objects.get(dn=member_dn) + url = reverse( + 'admin:ldap_ldapgroup_change', + args=[quote(ldap_group.dn, safe='')], + ) + html += format_html( + '
  3. 👥 {}
  4. ', + url, ldap_group.cn or '?', + ) + except LdapGroup.DoesNotExist: + html += format_html( + '
  5. {} ' + '(не найден)
  6. ', + member_dn, + ) + else: + html += format_html( + '
  7. {}
  8. ', member_dn, + ) + + if len(members) > 100: + html += format_html( + '
  9. ...и ещё {} участников
  10. ', + len(members) - 100, + ) + + html += '
' + return format_html(html) + members_list.short_description = 'Участники группы' + + def member_of_list(self, obj): + """Список групп, в которых состоит эта группа.""" + member_of = obj.member_of or [] + if not member_of: + return format_html( + 'Не состоит в группах' + ) + + html = '
' + html += '
' + return format_html(html) + member_of_list.short_description = 'Членство в группах' + diff --git a/backend/employees/ldap/mixins.py b/backend/employees/ldap/mixins.py index e7cc696d..b4f8ba86 100644 --- a/backend/employees/ldap/mixins.py +++ b/backend/employees/ldap/mixins.py @@ -107,25 +107,7 @@ class ModifyDnMixin: ) if needs_move: - old_dn = self._saved_dn - new_rdn = self.build_rdn() - new_superior = current_base_dn - new_dn = f"{new_rdn},{new_superior}" - - with _ldap() as conn: - success = conn.modify_dn( - old_dn, new_rdn, - new_superior=new_superior, - ) - if not success: - raise RuntimeError( - f"Failed to move LDAP object: {conn.result}" - ) - logger.info(f"ModifyDnMixin: Moved {old_dn} → {new_dn}") - - self._saved_dn = new_dn - self.dn = new_dn - self._original_base_dn = current_base_dn + self.move_to(current_base_dn) # --- 2) RDN-атрибут (cn/ou) изменился --- new_rdn_value = self._get_rdn_value() @@ -177,6 +159,44 @@ class ModifyDnMixin: return result + def move_to(self, new_base_dn): + """Переместить объект в другую OU без обновления атрибутов. + + Выполняет LDAP modify_dn с newsuperior. НЕ вызывает super().save(), + поэтому не пытается записать системные атрибуты (whenChanged и др.), + которые AD обновляет автоматически при перемещении. + + Args: + new_base_dn: Новый base DN (целевая OU). + Пример: "OU=Dismissed,OU=company,DC=robotail,DC=local" + + Raises: + RuntimeError: Если modify_dn не удался. + ValueError: Если объект ещё не сохранён в LDAP. + """ + from .infrastructure.connections import _ldap + + if not hasattr(self, '_saved_dn') or not self._saved_dn: + raise ValueError("Cannot move an object that hasn't been saved to LDAP yet") + + old_dn = self._saved_dn + new_rdn = self.build_rdn() + new_dn = f"{new_rdn},{new_base_dn}" + + with _ldap() as conn: + success = conn.modify_dn( + old_dn, new_rdn, + new_superior=new_base_dn, + ) + if not success: + raise RuntimeError(conn.result) + logger.info(f"ModifyDnMixin.move_to: {old_dn} → {new_dn}") + + self._saved_dn = new_dn + self.dn = new_dn + self.base_dn = new_base_dn + self._original_base_dn = new_base_dn + @classmethod def from_db(cls, db, field_names, values): """Переопределяем from_db для сохранения оригинальных значений при загрузке.""" diff --git a/backend/employees/ldap/orm_models.py b/backend/employees/ldap/orm_models.py index 6cd70402..00f9b885 100644 --- a/backend/employees/ldap/orm_models.py +++ b/backend/employees/ldap/orm_models.py @@ -80,8 +80,10 @@ DELETE пользователя: Группы (зависит от типа): - Глобальные группы: CN=Developers,OU=Groups,OU=company,DC=robotail,DC=local + - Группы OU (внутри OU): + CN=DEP_IT,OU=IT,OU=Departments,OU=company,DC=robotail,DC=local - Роли отделов: - CN=ROLE_Manager,OU=Roles,OU=IT,OU=Departments,OU=company,DC=robotail,DC=local + CN=ROLE_Manager,OU=IT,OU=Departments,OU=company,DC=robotail,DC=local Организационные единицы: - Отделы: @@ -98,7 +100,7 @@ base_dn используется ТОЛЬКО для SUBTREE поиска - LdapUser.base_dn = "OU=company,DC=..." — покрывает все отделы и Dismissed - LdapGroup.base_dn = "DC=..." — покрывает Groups и роли отделов -- LdapOrganizationalUnit.base_dn = "DC=..." — покрывает все OU +- LdapOrganizationalUnit.base_dn = "OU=Departments,..." — покрывает OU отделов """ import datetime as _dt @@ -173,9 +175,9 @@ def get_users_base(): def get_base_dn(): """Получает корневой base DN из settings. - Используется для групп и OU. Должен покрывать: + Используется для групп. Должен покрывать: - OU=Groups (глобальные группы) - - OU=Departments (отделы и их роли) + - OU=Departments (роли отделов) - Любые другие контейнеры Приоритет: @@ -190,6 +192,22 @@ def get_base_dn(): ) +def get_departments_base(): + """Получает base DN для отделов (OU) из settings. + + Отделы располагаются в фиксированном контейнере: + OU=,OU=Departments,OU=company,DC=robotail,DC=local + + Приоритет: + 1. LDAP_DEPARTMENTS_BASE (если задан) + 2. Fallback: OU=Departments,DC=robotail,DC=local + """ + return getattr( + settings, 'LDAP_DEPARTMENTS_BASE', + 'OU=Departments,DC=robotail,DC=local', + ) + + class LdapUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel): """LDAP модель для пользователя Active Directory. @@ -291,7 +309,8 @@ class LdapGroup(ModifyDnMixin, LdapModel): - base_dn используется только для SUBTREE поиска через .objects.filter() - Группы могут находиться в разных OU: • CN=Group,OU=Groups,DC=... (глобальные группы) - • CN=ROLE_*,OU=Roles,OU=,OU=Departments,... (роли отделов) + • CN=DEP_*,OU=,OU=Departments,... (группы отделов) + • CN=ROLE_*,OU=,OU=Departments,... (роли отделов) - При изменении base_dn ModifyDnMixin автоматически выполнит modify_dn! Операции через django-ldapdb: @@ -301,8 +320,10 @@ class LdapGroup(ModifyDnMixin, LdapModel): - RENAME: переименование через GroupService.rename() (low-level ldap3) - DELETE: удаление через .delete() (ORM) - При создании ролей отделов DN формируется как: - CN=ROLE_{name},OU=Roles,OU={dept},OU=Departments,... + Типы групп в LDAP: + - Глобальные группы: CN=,OU=Groups,... + - Группы отделов: CN=DEP_,OU=,OU=Departments,... + - Роли отделов: CN=ROLE_{name},OU={dept},OU=Departments,... """ # Базовая конфигурация @@ -342,6 +363,159 @@ class LdapGroup(ModifyDnMixin, LdapModel): return f"" +class LdapOrganizationalUnitGroup(ModifyDnMixin, LdapModel): + """LDAP модель группы организационной единицы (DEP_*). + + Каждая OU в LDAP имеет агрегаторную группу, + содержащую всех активных сотрудников этой OU: + CN=DEP_IT,OU=IT,OU=Departments,OU=company,DC=robotail,DC=local + + Миксины: + - ModifyDnMixin: перемещение при переименовании OU + + ВАЖНО О base_dn: + - base_dn = LDAP_DEPARTMENTS_BASE — SUBTREE поиск найдёт + все DEP_* группы внутри всех OU + - objectClass фильтрация: ищет только group (не OU) + + Операции: + - READ: .objects.filter(cn__startswith='DEP_') + - UPDATE: изменение description/member через .save() + - MOVE: group.base_dn = new_ou_dn; group.save() + - DELETE: .delete() + - CREATE: ensure() на LdapOrganizationalUnit (low-level ldap3) + + Управление членством: + - add_member(user_dn) → добавить пользователя + - remove_member(user_dn) → удалить пользователя + - sync_members(user_dns) → привести к точному списку + - list_members() → текущий список DN участников + """ + + # Базовая конфигурация: ищем группы внутри OU отделов + base_dn = get_departments_base() + object_classes = ['top', 'group'] + + rdn_attributes = ['cn'] + + # Основные атрибуты + cn = CharField(db_column='cn') + sam_account_name = CharField(db_column='sAMAccountName') + description = CharField(db_column='description', blank=True) + + # Члены группы + member = ListField(db_column='member', blank=True) + member_of = ListField(db_column='memberOf', blank=True) + + # Временные метки + when_created = DateTimeField(db_column='whenCreated') + when_changed = DateTimeField(db_column='whenChanged') + + class Meta: + managed = False + + def __str__(self): + return f"OUGroup: {self.cn}" + + def __repr__(self): + return f"" + + # ==================== Membership Methods ==================== # + + def list_members(self) -> list[str]: + """Возвращает список DN участников группы.""" + return list(self.member or []) + + def add_member(self, member_dn: str) -> None: + """Добавляет участника в группу отдела. + + Args: + member_dn: DN пользователя. + + Raises: + RuntimeError: Если операция не удалась. + """ + current = self.member or [] + if member_dn in current: + return + + from .infrastructure.connections import _ldap + from ldap3 import MODIFY_ADD + + with _ldap() as conn: + ok = conn.modify( + self.dn, + {"member": [(MODIFY_ADD, [member_dn])]} + ) + if not ok: + result_str = str(conn.result) + if "attributeOrValueExists" not in result_str: + raise RuntimeError( + f"add_member failed: {conn.result}" + ) + + def remove_member(self, member_dn: str) -> None: + """Удаляет участника из группы отдела. + + Args: + member_dn: DN пользователя. + + Raises: + RuntimeError: Если операция не удалась. + """ + from .infrastructure.connections import _ldap + from ldap3 import MODIFY_DELETE + + with _ldap() as conn: + ok = conn.modify( + self.dn, + {"member": [(MODIFY_DELETE, [member_dn])]} + ) + if not ok: + result_str = str(conn.result) + if "noSuchAttribute" not in result_str: + raise RuntimeError( + f"remove_member failed: {conn.result}" + ) + + def sync_members(self, desired_dns: list[str]) -> dict: + """Синхронизирует состав группы к точному списку. + + Добавляет недостающих, удаляет лишних. + + Args: + desired_dns: Целевой список DN участников. + + Returns: + dict: {'added': int, 'removed': int} + """ + current = set(self.member or []) + desired = set(desired_dns) + + to_add = desired - current + to_remove = current - desired + + from .infrastructure.connections import _ldap + from ldap3 import MODIFY_ADD, MODIFY_DELETE + + with _ldap() as conn: + if to_remove: + conn.modify( + self.dn, + {"member": [(MODIFY_DELETE, list(to_remove))]} + ) + if to_add: + conn.modify( + self.dn, + {"member": [(MODIFY_ADD, list(to_add))]} + ) + + return { + 'added': len(to_add), + 'removed': len(to_remove), + } + + class LdapOrganizationalUnit(ModifyDnMixin, LdapModel): """LDAP модель для Organizational Unit (отдел). @@ -363,12 +537,21 @@ class LdapOrganizationalUnit(ModifyDnMixin, LdapModel): - MOVE: ou.base_dn = new_dn; ou.save() (автоматический modify_dn!) - DELETE: удаление через ldap3.Connection.delete() - При создании отдела DN формируется как: - OU={dept_name},{LDAP_DEPARTMENTS_BASE} + Структура отдела в LDAP: + OU=,OU=Departments,... ← LdapOrganizationalUnit + ├─ CN=DEP_ ← LdapOrganizationalUnitGroup (все члены) + ├─ CN=ROLE_Manager ← LdapGroup (роль) + └─ CN=User1, CN=User2, ... ← LdapUser (пользователи) + + Методы работы с группой OU (DEP_*): + - get_department_group_dn() → DN группы OU + - get_department_group() → LdapOrganizationalUnitGroup или None + - ensure_department_group() → + создаёт/возвращает LdapOrganizationalUnitGroup """ # Базовая конфигурация - base_dn = get_base_dn() + base_dn = get_departments_base() object_classes = ['top', 'organizationalUnit'] # RDN атрибут для построения Distinguished Name @@ -401,9 +584,78 @@ class LdapOrganizationalUnit(ModifyDnMixin, LdapModel): def __repr__(self): return f"" + # ==================== Department Group Methods ==================== # + + def get_department_group_dn(self) -> str: + """Возвращает ожидаемый DN группы отдела DEP_*. + + Группа отдела находится внутри OU отдела: + CN=DEP_,OU=,OU=Departments,... + + Returns: + str: DN группы отдела. + """ + return f"CN=DEP_{self.ou},{self.dn}" + + def get_department_group(self): + """Возвращает группу OU (DEP_*), если существует. + + Returns: + LdapOrganizationalUnitGroup | None + """ + try: + return LdapOrganizationalUnitGroup.objects.get( + dn=self.get_department_group_dn() + ) + except LdapOrganizationalUnitGroup.DoesNotExist: + return None + + def ensure_department_group(self): + """Создаёт или возвращает группу OU (DEP_*). + + Если группа существует — возвращает её. + Если нет — создаёт через low-level ldap3. + + Returns: + LdapOrganizationalUnitGroup: Группа OU. + + Raises: + RuntimeError: Если создание группы не удалось. + """ + group = self.get_department_group() + if group: + return group + + from .infrastructure.connections import _ldap + from .utils.ldap_utils import group_type + from .utils.text_utils import esc_rdn + + cn = f"DEP_{self.ou}" + group_dn = f"CN={esc_rdn(cn)},{self.dn}" + + with _ldap() as conn: + attrs = { + "sAMAccountName": cn[:20], + "description": f"{self.ou} department members", + "groupType": group_type( + "global", security_enabled=True + ), + } + ok = conn.add(group_dn, ["top", "group"], attrs) + if not ok: + res = str(conn.result) + if "entryAlreadyExists" not in res: + raise RuntimeError( + f"Failed to create dept group: " + f"{conn.result}" + ) + + return LdapOrganizationalUnitGroup.objects.get(dn=group_dn) + __all__ = [ 'LdapUser', 'LdapGroup', + 'LdapOrganizationalUnitGroup', 'LdapOrganizationalUnit', ] diff --git a/backend/eusrr_backend/db_routers.py b/backend/eusrr_backend/db_routers.py index fe4006ff..756d2297 100644 --- a/backend/eusrr_backend/db_routers.py +++ b/backend/eusrr_backend/db_routers.py @@ -20,6 +20,7 @@ class LdapRouter: 'ldapuser', 'ldapgroup', 'ldaporganizationalunit', + 'ldaporganizationalunitgroup', } def db_for_read(self, model, **hints):