From 1a3d533c1df9e8853fca61d97e414f3c27f38702 Mon Sep 17 00:00:00 2001 From: lizerk Date: Sun, 22 Mar 2026 13:55:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0=20+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/employees/admin.py | 96 ++++++++++- backend/employees/ldap/admin.py | 242 +++++++++++++++++++++++++++ backend/employees/ldap/orm_models.py | 49 +++--- 3 files changed, 367 insertions(+), 20 deletions(-) diff --git a/backend/employees/admin.py b/backend/employees/admin.py index 5d9e5474..f157db0e 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -2,7 +2,7 @@ from django import forms from django.contrib import admin, messages -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin, GroupAdmin as DefaultGroupAdmin from django.contrib.auth.forms import ReadOnlyPasswordHashField, AdminPasswordChangeForm from django.contrib.auth.models import Group, Permission from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin @@ -1595,3 +1595,97 @@ class LdapSyncStateAdmin(admin.ModelAdmin): def has_delete_permission(self, request, obj=None): """Разрешаем удаление для очистки.""" return True + + +# ========================= +# Кастомный GroupAdmin с LDAP синхронизацией +# ========================= + +admin.site.unregister(Group) + + +@admin.register(Group) +class GroupAdmin(DefaultGroupAdmin): + """Расширенный GroupAdmin с кнопками синхронизации LDAP.""" + + list_display = ('name', 'ldap_dn_display') + actions = ['sync_groups_from_ldap', 'sync_groups_to_ldap'] + + def ldap_dn_display(self, obj): + sync = LdapSyncState.objects.filter( + model='group', object_pk=str(obj.pk), + ).first() + return sync.ldap_dn if sync else '—' + ldap_dn_display.short_description = 'LDAP DN' + + # ---------- LDAP → Django ---------- + def sync_groups_from_ldap(self, request, queryset): + """Синхронизирует ВСЕ группы из OU=Groups в Django Group.""" + from employees.ldap.services.group_service import GroupService + + svc = GroupService() + try: + created = svc.sync_catalog(throttle_seconds=0) + self.message_user( + request, + f'LDAP → Django: создано {created} новых Django Group', + level=messages.SUCCESS, + ) + except Exception as e: + self.message_user( + request, + f'Ошибка синхронизации: {e}', + level=messages.ERROR, + ) + sync_groups_from_ldap.short_description = '⬇️ LDAP → Django (создать Django Group)' + + # ---------- Django → LDAP ---------- + def sync_groups_to_ldap(self, request, queryset): + """Создаёт в LDAP группы, которых там ещё нет (с реальной проверкой LDAP).""" + from employees.ldap.orm_models import LdapGroup + from employees.ldap.services.group_service import GroupService + + svc = GroupService() + created = 0 + already = 0 + for dg in queryset: + # Реальная проверка наличия группы в LDAP + exists_in_ldap = LdapGroup.objects.filter(cn=dg.name).exists() + if exists_in_ldap: + ldap_grp = LdapGroup.objects.filter(cn=dg.name).first() + # Обновляем/создаём LdapSyncState на случай рассинхрона + LdapSyncState.objects.update_or_create( + model='group', + object_pk=str(dg.pk), + defaults={ + 'ldap_dn': ldap_grp.dn, + 'last_sync_dir': 'ldap', + }, + ) + already += 1 + continue + + try: + dn = svc.create(cn=dg.name) + LdapSyncState.objects.update_or_create( + model='group', + object_pk=str(dg.pk), + defaults={ + 'ldap_dn': dn, + 'last_sync_dir': 'django', + }, + ) + created += 1 + except Exception as e: + self.message_user( + request, + f'⚠️ {dg.name}: {e}', + level=messages.WARNING, + ) + + self.message_user( + request, + f'Django → LDAP: создано {created}, уже существовало {already}', + level=messages.SUCCESS, + ) + sync_groups_to_ldap.short_description = '⬆️ Django → LDAP (создать LDAP Group)' diff --git a/backend/employees/ldap/admin.py b/backend/employees/ldap/admin.py index 897d7d99..741d9445 100644 --- a/backend/employees/ldap/admin.py +++ b/backend/employees/ldap/admin.py @@ -1280,6 +1280,11 @@ class LdapOrganizationalUnitGroupAdmin(admin.ModelAdmin): list_per_page = 100 + actions = [ + 'show_members_info', + 'sync_members_from_django', + ] + def has_add_permission(self, request): """Группы создаются автоматически через OU.""" return False @@ -1288,6 +1293,81 @@ class LdapOrganizationalUnitGroupAdmin(admin.ModelAdmin): """Удаление через LDAP OU admin.""" return False + def _resolve_ou_groups(self, request): + """Получает выбранные OU группы по DN из POST данных.""" + selected_dns = request.POST.getlist('_selected_action') + groups = [] + for dn in selected_dns: + try: + group = LdapOrganizationalUnitGroup.objects.get(dn=dn) + groups.append(group) + except LdapOrganizationalUnitGroup.DoesNotExist: + pass + return groups + + # --- Actions --- + + def show_members_info(self, request, queryset): + """Показывает информацию об участниках.""" + groups = self._resolve_ou_groups(request) + for group in groups: + members = group.member or [] + self.message_user( + request, + f'👥 {group.cn}: {len(members)} участников', + level=messages.INFO, + ) + show_members_info.short_description = '📋 Показать состав' + + def sync_members_from_django(self, request, queryset): + """Синхронизирует участников из Django EmployeeDepartment.""" + groups = self._resolve_ou_groups(request) + if not groups: + self.message_user( + request, 'Не удалось найти выбранные группы.', + level=messages.WARNING, + ) + return + + from employees.models import EmployeeDepartment, Department + + for group in groups: + cn = get_ldap_str(group.cn) or '' + if not cn.startswith('DEP_'): + continue + dept_name = cn[4:] + try: + dept = Department.objects.get(name=dept_name) + except Department.DoesNotExist: + self.message_user( + request, + f'⚠️ {cn}: Django Department "{dept_name}" не найден', + level=messages.WARNING, + ) + continue + + 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 = group.sync_members(member_dns) + self.message_user( + request, + f'👥 {cn}: +{result["added"]}/−{result["removed"]} ' + f'(всего: {len(member_dns)})', + level=messages.SUCCESS, + ) + sync_members_from_django.short_description = '🔄 Синхронизировать участников из Django' + def _find_django_department(self, ou_group): """Находит Department по имени группы DEP_*.""" from employees.models import Department @@ -1586,6 +1666,13 @@ class LdapGroupAdmin(admin.ModelAdmin): list_per_page = 100 + actions = [ + 'show_group_info', + 'sync_groups_from_ldap', + 'sync_groups_to_ldap', + 'delete_selected_ldap', + ] + def has_add_permission(self, request): """Группы создаются через GroupService или автоматически.""" return False @@ -1594,6 +1681,161 @@ class LdapGroupAdmin(admin.ModelAdmin): """Удаление через LDAP services.""" return False + def _resolve_ldap_groups(self, request): + """Получает выбранные LDAP группы по DN из POST данных.""" + selected_dns = request.POST.getlist('_selected_action') + groups = [] + for dn in selected_dns: + try: + group = LdapGroup.objects.get(dn=dn) + groups.append(group) + except LdapGroup.DoesNotExist: + pass + return groups + + # --- Actions --- + + def show_group_info(self, request, queryset): + """Показывает информацию о выбранных группах.""" + groups = self._resolve_ldap_groups(request) + if not groups: + self.message_user( + request, 'Не удалось найти выбранные группы.', + level=messages.WARNING, + ) + return + for group in groups: + members = group.member or [] + member_of = group.member_of or [] + self.message_user( + request, + f'👥 {group.cn}: {len(members)} участников, ' + f'входит в {len(member_of)} групп | DN: {group.dn}', + level=messages.INFO, + ) + show_group_info.short_description = '📋 Показать информацию' + + def delete_selected_ldap(self, request, queryset): + """Удаляет выбранные LDAP группы.""" + groups = self._resolve_ldap_groups(request) + if not groups: + self.message_user( + request, 'Не удалось найти выбранные группы.', + level=messages.WARNING, + ) + return + + deleted = 0 + for group in groups: + try: + group.delete() + deleted += 1 + except Exception as e: + self.message_user( + request, + f'Ошибка удаления {group.cn}: {e}', + level=messages.ERROR, + ) + if deleted: + self.message_user( + request, + f'Удалено: {deleted} групп из LDAP.', + level=messages.SUCCESS, + ) + delete_selected_ldap.short_description = '🗑️ Удалить из LDAP' + + def sync_groups_from_ldap(self, request, queryset): + """Синхронизирует ВСЕ группы из OU=Groups в Django Group.""" + from employees.ldap.services.group_service import GroupService + + svc = GroupService() + try: + created = svc.sync_catalog(throttle_seconds=0) + self.message_user( + request, + f'🔄 LDAP → Django: создано {created} новых Django Group', + level=messages.SUCCESS, + ) + except Exception as e: + self.message_user( + request, + f'Ошибка синхронизации: {e}', + level=messages.ERROR, + ) + sync_groups_from_ldap.short_description = '⬇️ LDAP → Django (создать Django Group)' + + def sync_groups_to_ldap(self, request, queryset): + """Синхронизирует Django Groups → LDAP (создаёт отсутствующие, проверяя LDAP).""" + from django.contrib.auth.models import Group as DjangoGroup + from employees.ldap.services.group_service import GroupService + + svc = GroupService() + groups = self._resolve_ldap_groups(request) + + if not groups: + # Если ничего не выбрано — синхронизируем все Django Groups + django_groups = DjangoGroup.objects.all() + created = 0 + already = 0 + for dg in django_groups: + # Реальная проверка наличия в LDAP + exists_in_ldap = LdapGroup.objects.filter(cn=dg.name).exists() + if exists_in_ldap: + ldap_grp = LdapGroup.objects.filter(cn=dg.name).first() + LdapSyncState.objects.update_or_create( + model='group', + object_pk=str(dg.pk), + defaults={ + 'ldap_dn': ldap_grp.dn, + 'last_sync_dir': 'ldap', + }, + ) + already += 1 + continue + + try: + dn = svc.create(cn=dg.name) + LdapSyncState.objects.update_or_create( + model='group', + object_pk=str(dg.pk), + defaults={ + 'ldap_dn': dn, + 'last_sync_dir': 'django', + }, + ) + created += 1 + except Exception as e: + self.message_user( + request, + f'⚠️ {dg.name}: {e}', + level=messages.WARNING, + ) + + self.message_user( + request, + f'🔄 Django → LDAP: создано {created}, уже существовало {already}', + level=messages.SUCCESS, + ) + else: + # Показываем info о выбранных LDAP-группах + for group in groups: + sync = LdapSyncState.objects.filter( + model='group', ldap_dn=group.dn, + ).first() + if sync: + self.message_user( + request, + f'✅ {group.cn}: связана с Django Group pk={sync.object_pk}', + level=messages.INFO, + ) + else: + self.message_user( + request, + f'⚠️ {group.cn}: нет связи с Django Group', + level=messages.WARNING, + ) + sync_groups_to_ldap.short_description = '⬆️ Django → LDAP (создать LDAP Group)' + # --- Кастомные поля для отображения --- def group_display(self, obj): diff --git a/backend/employees/ldap/orm_models.py b/backend/employees/ldap/orm_models.py index 00f9b885..f79ef900 100644 --- a/backend/employees/ldap/orm_models.py +++ b/backend/employees/ldap/orm_models.py @@ -99,7 +99,7 @@ base_dn используется ТОЛЬКО для SUBTREE поиска и НЕ определяет местоположение создаваемых/перемещаемых объектов. - LdapUser.base_dn = "OU=company,DC=..." — покрывает все отделы и Dismissed -- LdapGroup.base_dn = "DC=..." — покрывает Groups и роли отделов +- LdapGroup.base_dn = "OU=Groups,..." — покрывает только глобальные группы - LdapOrganizationalUnit.base_dn = "OU=Departments,..." — покрывает OU отделов """ @@ -175,15 +175,11 @@ def get_users_base(): def get_base_dn(): """Получает корневой base DN из settings. - Используется для групп. Должен покрывать: - - OU=Groups (глобальные группы) - - OU=Departments (роли отделов) - - Любые другие контейнеры + Используется для операций, которым нужен охват всего домена. Приоритет: 1. LDAP_BASE_DN (если задан) - 2. Fallback: корневой DN домена (DC=robotail,DC=local) - или LDAP_USERS_BASE если нет LDAP_BASE_DN + 2. Fallback: LDAP_USERS_BASE или DC=robotail,DC=local """ return getattr( settings, 'LDAP_BASE_DN', @@ -192,6 +188,22 @@ def get_base_dn(): ) +def get_groups_base(): + """Получает base DN для глобальных групп из settings. + + Глобальные группы располагаются в: + CN=,OU=Groups,OU=company,DC=robotail,DC=local + + Приоритет: + 1. LDAP_GROUPS_BASE (если задан) + 2. Fallback: OU=Groups,DC=robotail,DC=local + """ + return getattr( + settings, 'LDAP_GROUPS_BASE', + 'OU=Groups,DC=robotail,DC=local', + ) + + def get_departments_base(): """Получает base DN для отделов (OU) из settings. @@ -297,7 +309,7 @@ class LdapUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel): class LdapGroup(ModifyDnMixin, LdapModel): - """LDAP модель для группы Active Directory. + """LDAP модель для глобальных групп Active Directory. Использует objectClass: top, group. Только для WRITE операций (POST/PUT/DELETE). @@ -306,11 +318,9 @@ class LdapGroup(ModifyDnMixin, LdapModel): - ModifyDnMixin: поддержка перемещения между OU через base_dn изменение ВАЖНО О base_dn: - - base_dn используется только для SUBTREE поиска через .objects.filter() - - Группы могут находиться в разных OU: - • CN=Group,OU=Groups,DC=... (глобальные группы) - • CN=DEP_*,OU=,OU=Departments,... (группы отделов) - • CN=ROLE_*,OU=,OU=Departments,... (роли отделов) + - base_dn = LDAP_GROUPS_BASE — сканирует ТОЛЬКО OU=Groups + - Для групп отделов (DEP_*): используйте LdapOrganizationalUnitGroup + - Для должностей (POS_*): используйте PositionService - При изменении base_dn ModifyDnMixin автоматически выполнит modify_dn! Операции через django-ldapdb: @@ -320,14 +330,15 @@ class LdapGroup(ModifyDnMixin, LdapModel): - RENAME: переименование через GroupService.rename() (low-level ldap3) - DELETE: удаление через .delete() (ORM) - Типы групп в LDAP: - - Глобальные группы: CN=,OU=Groups,... - - Группы отделов: CN=DEP_,OU=,OU=Departments,... - - Роли отделов: CN=ROLE_{name},OU={dept},OU=Departments,... + Расположение групп по типам: + - Глобальные: CN=,OU=Groups,... ← эта модель + - Отделы: CN=DEP_*,OU=,OU=Departments ← LdapOrganizationalUnitGroup + - Должности: CN=POS_*,OU=Positions,... ← PositionService + - Роли: CN=ROLE_*,OU=,OU=Departments ← через GroupService """ - # Базовая конфигурация - base_dn = get_base_dn() + # Базовая конфигурация: только OU=Groups + base_dn = get_groups_base() object_classes = ['top', 'group'] # RDN атрибут для построения Distinguished Name