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(
+ '- {} ({})
',
+ url,
+ ldap_user.display_name or ldap_user.cn or '?',
+ ldap_user.sam_account_name or '',
+ )
+ except LdapUser.DoesNotExist:
+ html += format_html(
+ '{} '
+ '(не найден) ',
+ member_dn,
+ )
+
+ if len(members) > 50:
+ html += format_html(
+ '- ...и ещё {} участников
',
+ 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(
+ '- 👤 {} ({})
',
+ 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(
+ '- 👥 {}
',
+ url, ldap_group.cn or '?',
+ )
+ except LdapGroup.DoesNotExist:
+ html += format_html(
+ '{} '
+ '(не найден) ',
+ member_dn,
+ )
+ else:
+ html += format_html(
+ '{} ', member_dn,
+ )
+
+ if len(members) > 100:
+ html += format_html(
+ '- ...и ещё {} участников
',
+ 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 += '
'
+
+ for group_dn in member_of[:50]:
+ # Извлекаем CN из DN
+ cn = group_dn.split(',')[0].replace('CN=', '')
+ try:
+ parent_group = LdapGroup.objects.get(dn=group_dn)
+ url = reverse(
+ 'admin:ldap_ldapgroup_change',
+ args=[quote(parent_group.dn, safe='')],
+ )
+ html += format_html(
+ '- 👥 {}
',
+ url, parent_group.cn or cn,
+ )
+ except LdapGroup.DoesNotExist:
+ html += format_html(
+ '{} ', cn,
+ )
+
+ if len(member_of) > 50:
+ html += format_html(
+ '- ...и ещё {} групп
',
+ len(member_of) - 50,
+ )
+
+ 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):