админка+

This commit is contained in:
lizerk 2026-03-20 14:44:41 +03:00
parent 2688df46ef
commit 4c5ea98743
7 changed files with 2077 additions and 61 deletions

View File

@ -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(
'<span style="color: red;" title="Нет записи синхронизации">'
'<a href="{}?model=department&object_pk={}" style="color: red;">❌ Нет записи</a>'
'</span>',
create_url,
obj.pk
)
# Проверяем полноту данных
has_dn = bool(sync_state.ldap_dn)
has_updated = bool(sync_state.updated_at)
# Полные данные
if has_dn and has_updated:
# Определяем направление последней синхронизации
if sync_state.last_sync_dir == 'ldap':
icon = '⬇️'
title_dir = 'LDAP → Django'
elif sync_state.last_sync_dir == 'django':
icon = '⬆️'
title_dir = 'Django → LDAP'
else:
icon = '✅'
title_dir = 'Синхронизирован'
dn_short = sync_state.ldap_dn[:60] + '...' if len(sync_state.ldap_dn) > 60 else sync_state.ldap_dn
return format_html(
'{} <span title="DN: {}\nНаправление: {}">{}</span>',
icon,
dn_short,
title_dir,
sync_state.updated_at.strftime("%d.%m.%Y %H:%M")
)
# Неполные данные
elif has_updated:
return format_html(
'<span style="color: orange;" title="Отсутствует DN">⚠️ {}</span>',
sync_state.updated_at.strftime("%d.%m.%Y %H:%M")
)
else:
return format_html(
'<span style="color: orange;" title="Нет даты обновления">⚠️ Неполные данные</span>'
)
except Exception as e:
return format_html(
'<span style="color: red;" title="Ошибка: {}">❌ Ошибка</span>',
str(e)
)
ldap_sync_status.short_description = "LDAP статус"
ldap_sync_status.admin_order_field = "id"
def ldap_sync_info(self, obj):
"""Подробная информация о синхронизации для формы."""
if not obj.pk:
return "-"
from django.utils.html import format_html
from django.urls import reverse
try:
sync_state = LdapSyncState.objects.filter(
model='department',
object_pk=str(obj.pk)
).first()
if not sync_state:
create_url = reverse('admin:employees_ldapsyncstate_add')
return format_html(
'<div style="padding:10px;background:#f8d7da;border-left:4px solid #dc3545;">'
'❌ <strong>Нет записи синхронизации</strong><br>'
'<small>Создайте запись синхронизации или выполните синхронизацию.</small><br>'
'<a href="{}?model=department&object_pk={}" target="_blank">➕ Создать запись</a>'
'</div>',
create_url,
obj.pk
)
# Формируем подробную информацию
sync_url = reverse('admin:employees_ldapsyncstate_change', args=[sync_state.pk])
if sync_state.last_sync_dir == 'ldap':
direction = '⬇️ LDAP → Django'
color = '#17a2b8'
elif sync_state.last_sync_dir == 'django':
direction = '⬆️ Django → LDAP'
color = '#0066cc'
else:
direction = '↔️ Неизвестно'
color = '#6c757d'
return format_html(
'<div style="padding:10px;background:#d1ecf1;border-left:4px solid {};">'
'<strong>Информация о синхронизации:</strong><br>'
'<table style="margin-top:5px;">'
'<tr><td><strong>LDAP DN:</strong></td><td><code style="font-size:0.85em;">{}</code></td></tr>'
'<tr><td><strong>Направление:</strong></td><td>{}</td></tr>'
'<tr><td><strong>Обновлено:</strong></td><td>{}</td></tr>'
'</table>'
'<a href="{}" target="_blank" style="margin-top:10px;display:inline-block;">📋 Открыть запись</a>'
'</div>',
color,
sync_state.ldap_dn or '—',
direction,
sync_state.updated_at.strftime('%Y-%m-%d %H:%M:%S') if sync_state.updated_at else '—',
sync_url
)
except Exception as e:
return format_html(
'<div style="padding:10px;background:#f8d7da;border-left:4px solid #dc3545;">'
'❌ <strong>Ошибка:</strong> {}'
'</div>',
str(e)
)
ldap_sync_info.short_description = "Статус синхронизации"
@admin.action(description='🔄 Синхронизировать LDAP → Django (LDAP как источник истины)')
def sync_from_ldap_to_django(self, request, queryset):
"""Синхронизирует выбранные отделы из LDAP OU в Django Department."""
from employees.ldap.orm_models import LdapOrganizationalUnit
from employees.ldap.utils.ldap_utils import get_ldap_str
success_count = 0
error_count = 0
warnings = []
for dept in queryset:
# Ищем соответствующую OU в LDAP
sync_state = LdapSyncState.objects.filter(
model='department',
object_pk=str(dept.pk),
).first()
ldap_ou = None
if sync_state and sync_state.ldap_dn:
try:
ldap_ou = LdapOrganizationalUnit.objects.get(dn=sync_state.ldap_dn)
except LdapOrganizationalUnit.DoesNotExist:
warnings.append(f'⚠️ {dept.name}: OU с DN {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)

View File

@ -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

View File

@ -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",
# Подключения

File diff suppressed because it is too large Load Diff

View File

@ -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 для сохранения оригинальных значений при загрузке."""

View File

@ -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=<DeptName>,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=<Dept>,OU=Departments,... (роли отделов)
• CN=DEP_*,OU=<Dept>,OU=Departments,... (группы отделов)
• CN=ROLE_*,OU=<Dept>,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=<Name>,OU=Groups,...
- Группы отделов: CN=DEP_<DeptName>,OU=<DeptName>,OU=Departments,...
- Роли отделов: CN=ROLE_{name},OU={dept},OU=Departments,...
"""
# Базовая конфигурация
@ -342,6 +363,159 @@ class LdapGroup(ModifyDnMixin, LdapModel):
return f"<LdapGroup: {self.cn}>"
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"<LdapOrganizationalUnitGroup: {self.cn}>"
# ==================== 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=<DeptName>,OU=Departments,... ← LdapOrganizationalUnit
├─ CN=DEP_<DeptName> ← 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"<LdapOrganizationalUnit: {self.ou}>"
# ==================== Department Group Methods ==================== #
def get_department_group_dn(self) -> str:
"""Возвращает ожидаемый DN группы отдела DEP_*.
Группа отдела находится внутри OU отдела:
CN=DEP_<DeptName>,OU=<DeptName>,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',
]

View File

@ -20,6 +20,7 @@ class LdapRouter:
'ldapuser',
'ldapgroup',
'ldaporganizationalunit',
'ldaporganizationalunitgroup',
}
def db_for_read(self, model, **hints):