feat(groups): добавить Django сигналы для LDAP синхронизации + упростить GroupViewSet

Создан signals_group.py:
- post_save(Group): создание/обновление LdapGroup (cn, description, scope, security)
- post_delete(Group): удаление LdapGroup
- m2m_changed(Group.user_set): синхронизация участников (add/remove/clear)

Упрощен GroupViewSet (641 → 339 строк, -47%):
- create(): только создание в БД + временные атрибуты для сигнала
- partial_update(): передача _ldap_old_name и _ldap_description в сигнал
- destroy(): базовый ViewSet метод
- list(): удален sync_groups_catalog (должен быть в фоне/cron)
- rename: упрощен до установки _ldap_old_name и save()
- set_description: упрощен до установки _ldap_description и save()
- members: только БД запрос (без LDAP)
- add_members: user_set.add(), сигнал синхронизирует в LDAP
- remove_members: user_set.remove(), сигнал синхронизирует в LDAP
- replace_members: user_set.set(), сигнал синхронизирует в LDAP

Удалено:
- Все if ldap_enabled блоки
- Прямые вызовы DirectoryService в ViewSet
- Rollback логика при ошибках LDAP
- Helper методы: _resolve_group_dn, _members_payload_to_dns, _dns_to_users
- Импорты: DirectoryService, settings, transaction, LdapSyncState, _is_ldap_enabled

Зарегистрирован сигнал в employees/apps.py
This commit is contained in:
lizerk 2026-03-18 18:11:54 +03:00
parent 1bc6961b1d
commit 18ca956348
3 changed files with 342 additions and 453 deletions

View File

@ -1,16 +1,12 @@
"""GroupViewSet — CRUD и LDAP-операции с группами."""
"""GroupViewSet — CRUD и операции с группами."""
from __future__ import annotations
from typing import Any, List, Optional
from typing import List, Optional
from django.conf import settings
from django.contrib.auth.models import Group, Permission
from django.core.exceptions import FieldError
from django.db import transaction
from django.db.models import OuterRef, Q, Subquery
from employees.ldap.directory_service import DirectoryService
from employees.models import LdapSyncState
from django.db.models import Q
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
@ -18,7 +14,7 @@ from rest_framework.response import Response
from ...permissions import AdminOrActionOrModelPerms
from ..serializers import GroupSerializer
from ._helpers import Employee, _is_ldap_enabled
from ._helpers import Employee
class GroupViewSet(viewsets.ModelViewSet):
@ -109,120 +105,28 @@ class GroupViewSet(viewsets.ModelViewSet):
)
return list(qs), None
def _resolve_group_dn(self, grp: Group) -> Optional[str]:
"""Определяет DN группы по её CN."""
if not _is_ldap_enabled():
return None
base = getattr(settings, "LDAP_GROUPS_BASE", "") or None
svc = DirectoryService()
try:
return svc.group_find_dn(grp.name, bases=[base] if base else None)
except AttributeError:
return svc.find_group_dn(grp.name, bases=[base] if base else None)
def _members_payload_to_dns(self, payload: dict[str, Any]) -> list[str]:
"""Извлекает список DN участников из payload (member_dns|member_ids)."""
dns: list[str] = []
raw_dns = payload.get("member_dns") or []
if isinstance(raw_dns, list):
dns.extend([d.strip()
for d in raw_dns if isinstance(d, str) and d.strip()])
ids = payload.get("member_ids") or []
if isinstance(ids, list) and ids:
if _is_ldap_enabled():
svc = DirectoryService()
dns.extend(
svc.employee_ids_to_dns(
[i for i in ids if isinstance(i, int)])
)
uniq, seen = [], set()
for d in dns:
if d and d not in seen:
uniq.append(d)
seen.add(d)
if not _is_ldap_enabled():
return []
if not uniq:
raise ValueError(
"Не переданы корректные member_dns или member_ids")
return uniq
def _dns_to_users(self, dns):
"""Маппит DN участников на локальных пользователей через LdapSyncState."""
if not dns:
return []
sub = LdapSyncState.objects.filter(
model="employee", object_pk=OuterRef("pk")
).values_list("ldap_dn", flat=True)[:1]
return list(
Employee.objects.annotate(ldap_dn=Subquery(sub))
.filter(ldap_dn__in=dns)
.only("id")
)
# ---------- override CRUD: LDAP → DB ----------
# ---------- override CRUD ----------
def create(self, request, *args, **kwargs) -> Response:
"""Создаёт LDAP-группу, затем запись Group в БД."""
"""Создаёт Group в БД. Синхронизация в LDAP через сигналы."""
ser = self.get_serializer(data=request.data)
ser.is_valid(raise_exception=True)
name: str = ser.validated_data["name"]
parent_dn = request.data.get("ldap_parent_dn") or getattr(
settings, "LDAP_GROUPS_BASE", None
)
description = request.data.get("ldap_description")
scope = request.data.get("ldap_scope", "global")
security_enabled = bool(request.data.get("ldap_security", True))
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
svc = DirectoryService()
try:
svc.group_create(
cn=name,
parent_dn=parent_dn,
description=description,
scope=scope,
security_enabled=security_enabled,
)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
try:
grp = Group.objects.create(name=name)
perms = ser.validated_data.get("permissions")
if perms:
grp.permissions.set(perms)
except Exception as e:
if ldap_enabled:
try:
svc = DirectoryService()
dn = None
try:
dn = svc.group_find_dn(
name, bases=[parent_dn] if parent_dn else None
)
except AttributeError:
dn = svc.find_group_dn(
name, bases=[parent_dn] if parent_dn else None
)
if dn:
svc.group_delete(dn)
except Exception:
pass
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
grp = Group.objects.create(name=ser.validated_data["name"])
# Устанавливаем LDAP-специфичные атрибуты для сигнала
grp._ldap_parent_dn = request.data.get("ldap_parent_dn")
grp._ldap_description = request.data.get("ldap_description")
grp._ldap_scope = request.data.get("ldap_scope", "global")
grp._ldap_security_enabled = bool(request.data.get("ldap_security", True))
perms = ser.validated_data.get("permissions")
if perms:
grp.permissions.set(perms)
# Сохраняем для триггера сигнала создания
grp.save()
out = self.get_serializer(grp)
return Response(
out.data,
@ -231,65 +135,25 @@ class GroupViewSet(viewsets.ModelViewSet):
)
def partial_update(self, request, *args, **kwargs) -> Response:
"""Частичное обновление: сначала LDAP (rename/description), затем БД."""
"""Частичное обновление Group. Синхронизация в LDAP через сигналы."""
grp = self.get_object()
new_name = request.data.get("name")
new_desc = request.data.get("ldap_description", "__NO_CHANGE__")
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
svc = DirectoryService()
try:
if new_name and new_name != grp.name:
dn = svc.group_rename(dn, new_name)
if new_desc != "__NO_CHANGE__":
svc.group_set_description(dn, (new_desc or None))
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
# Устанавливаем LDAP-специфичные атрибуты для сигнала
if new_name and new_name != grp.name:
grp.name = new_name
grp.save(update_fields=["name"])
grp._ldap_old_name = grp.name
if new_desc != "__NO_CHANGE__":
grp._ldap_description = new_desc
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs) -> Response:
"""Удаляет LDAP-группу, затем запись Group в БД."""
grp = self.get_object()
force_db = str(request.query_params.get("force_db", "")).lower() in {
"1",
"true",
"yes",
}
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if dn:
try:
DirectoryService().group_delete(dn)
except Exception as e:
if not force_db:
return Response(
{"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY
)
"""Удаляет Group из БД. Синхронизация в LDAP через сигналы."""
return super().destroy(request, *args, **kwargs)
def list(self, request, *args, **kwargs) -> Response:
"""Список групп. Перед выдачей — мягкий LDAP-синк каталога."""
if _is_ldap_enabled():
try:
DirectoryService().sync_groups_catalog(throttle_seconds=60)
except Exception:
pass
"""Список групп."""
return super().list(request, *args, **kwargs)
# ---------- Actions: Django permissions ----------
@ -365,7 +229,7 @@ class GroupViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def rename(self, request, pk=None) -> Response:
"""Переименовывает LDAP-группу и синхронизирует имя в БД."""
"""Переименовывает группу. Синхронизация в LDAP через сигналы."""
grp = self.get_object()
new_name = (request.data.get("new_name") or "").strip()
if not new_name:
@ -373,320 +237,103 @@ class GroupViewSet(viewsets.ModelViewSet):
{"detail": "new_name обязателен"}, status=status.HTTP_400_BAD_REQUEST
)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
svc = DirectoryService()
try:
svc.group_rename(dn, new_name)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
grp._ldap_old_name = grp.name
grp.name = new_name
grp.save(update_fields=["name"])
return Response({"ok": True, "name": grp.name}, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="set-description")
def set_description(self, request, pk=None) -> Response:
"""Устанавливает описание LDAP-группы."""
"""Устанавливает описание группы. Синхронизация в LDAP через сигналы."""
grp = self.get_object()
if not _is_ldap_enabled():
return Response({"ok": True}, status=status.HTTP_200_OK)
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"}, status=status.HTTP_404_NOT_FOUND
)
svc = DirectoryService()
try:
svc.group_set_description(dn, request.data.get("description"))
return Response({"ok": True}, status=status.HTTP_200_OK)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
grp._ldap_description = request.data.get("description")
grp.save()
return Response({"ok": True}, status=status.HTTP_200_OK)
@action(detail=True, methods=["get"])
def members(self, request, pk=None) -> Response:
"""Состав LDAP-группы."""
"""Состав группы."""
grp = self.get_object()
if not _is_ldap_enabled():
users = grp.user_set.all()
employees = [
{
"id": u.id,
"email": u.email,
"first_name": u.first_name,
"last_name": u.last_name,
}
for u in users
]
return Response(
{"dns": [], "employees": employees}, status=status.HTTP_200_OK
)
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"}, status=status.HTTP_404_NOT_FOUND
)
svc = DirectoryService()
try:
dns = svc.group_list_members(dn)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
employees = svc.employees_brief_by_dns(dns)
return Response({"dns": dns, "employees": employees}, status=status.HTTP_200_OK)
users = grp.user_set.all()
employees = [
{
"id": u.id,
"email": u.email,
"first_name": u.first_name,
"last_name": u.last_name,
}
for u in users
]
return Response({"employees": employees}, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="add-members")
def add_members(self, request, pk=None) -> Response:
"""Добавляет участников в LDAP-группу и связывает в БД."""
"""Добавляет участников в группу. Синхронизация в LDAP через сигналы."""
grp = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
try:
member_dns = self._members_payload_to_dns(request.data)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
svc = DirectoryService()
try:
svc.group_add_members(dn, member_dns)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
users = self._dns_to_users(member_dns)
ok_user_ids = [u.id for u in users]
try:
with transaction.atomic():
grp.user_set.add(*users)
except Exception as e:
try:
svc.group_remove_members(dn, member_dns)
except Exception:
pass
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{
"ok": True,
"ldap_added": len(member_dns),
"db_added": len(users),
"ok_dns": member_dns,
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
users = Employee.objects.filter(id__in=member_ids)
try:
with transaction.atomic():
grp.user_set.add(*users)
except Exception as e:
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"ldap_added": 0,
"db_added": len(users),
"ok_dns": [],
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
users = Employee.objects.filter(id__in=member_ids)
grp.user_set.add(*users)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"db_added": len(users),
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"], url_path="remove-members")
def remove_members(self, request, pk=None) -> Response:
"""Удаляет участников из LDAP-группы и разрывает связи в БД."""
"""Удаляет участников из группы. Синхронизация в LDAP через сигналы."""
grp = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
try:
member_dns = self._members_payload_to_dns(request.data)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
svc = DirectoryService()
try:
svc.group_remove_members(dn, member_dns)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
users = self._dns_to_users(member_dns)
ok_user_ids = [u.id for u in users]
try:
with transaction.atomic():
grp.user_set.remove(*users)
except Exception as e:
try:
svc.group_add_members(dn, member_dns)
except Exception:
pass
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{
"ok": True,
"ldap_removed": len(member_dns),
"db_removed": len(users),
"ok_dns": member_dns,
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
users = Employee.objects.filter(id__in=member_ids)
try:
with transaction.atomic():
grp.user_set.remove(*users)
except Exception as e:
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"ldap_removed": 0,
"db_removed": len(users),
"ok_dns": [],
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
users = Employee.objects.filter(id__in=member_ids)
grp.user_set.remove(*users)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"db_removed": len(users),
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"], url_path="replace-members")
def replace_members(self, request, pk=None) -> Response:
"""Полностью заменяет состав LDAP-группы и синхронизирует M2M в БД."""
"""Полностью заменяет состав группы. Синхронизация в LDAP через сигналы."""
grp = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
try:
desired_dns = self._members_payload_to_dns(request.data)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
svc = DirectoryService()
try:
prev_dns = svc.group_list_members(dn)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
try:
svc.group_replace_members(dn, desired_dns)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
users = self._dns_to_users(desired_dns)
ok_user_ids = [u.id for u in users]
try:
with transaction.atomic():
grp.user_set.set(users)
except Exception as e:
try:
svc.group_replace_members(dn, prev_dns)
except Exception:
pass
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{
"ok": True,
"ldap_total": len(desired_dns),
"db_total": len(users),
"ok_dns": desired_dns,
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
users = Employee.objects.filter(id__in=member_ids)
try:
with transaction.atomic():
grp.user_set.set(users)
except Exception as e:
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"ldap_total": 0,
"db_total": len(users),
"ok_dns": [],
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
users = Employee.objects.filter(id__in=member_ids)
grp.user_set.set(users)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"db_total": len(users),
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)

View File

@ -10,4 +10,5 @@ class EmployeesConfig(AppConfig):
import employees.signals_birthday # Сигналы для синхронизации дней рождений с django-scheduler
import employees.signals_ldap # Сигналы для автоматической синхронизации Employee с LDAP
import employees.signals_department # Сигналы для автоматической синхронизации Department с LDAP
import employees.signals_group # Сигналы для автоматической синхронизации Group с LDAP
import employees.rules # django-rules: регистрация предикатов и правил доступа

View File

@ -0,0 +1,241 @@
"""Django signals для автоматической синхронизации Group с LDAP."""
import logging
from django.conf import settings
from django.contrib.auth.models import Group
from django.db.models.signals import m2m_changed, post_delete, post_save
from django.dispatch import receiver
from employees.ldap.directory_service import DirectoryService
from employees.ldap.errors import (
DirectoryDbError,
DirectoryLdapError,
DirectoryServiceError,
)
from employees.models import LdapSyncState
logger = logging.getLogger(__name__)
def _is_ldap_enabled():
"""Проверяет, включена ли интеграция с LDAP."""
return getattr(settings, "LDAP_ENABLED", False)
def _resolve_group_dn(group: Group) -> str | None:
"""Возвращает DN группы из LdapSyncState."""
sync_state = LdapSyncState.objects.filter(
model="group", object_pk=str(group.pk)
).first()
if not sync_state or not sync_state.ldap_dn:
return None
return sync_state.ldap_dn
@receiver(post_save, sender=Group)
def sync_group_to_ldap_on_save(sender, instance, created, **kwargs):
"""Синхронизирует изменения Group с LDAP.
При создании: создает LdapGroup
При обновлении: синхронизирует имя и описание
Использует временные атрибуты для передачи LDAP-специфичных данных:
- _ldap_parent_dn: родительский DN для новой группы
- _ldap_description: описание группы
- _ldap_scope: scope ('global', 'universal', 'domainlocal')
- _ldap_security_enabled: security enabled флаг
"""
if not _is_ldap_enabled():
return
if getattr(instance, '_skip_ldap_sync', False):
return
try:
svc = DirectoryService()
if created:
# Создание новой LDAP группы
parent_dn = getattr(instance, '_ldap_parent_dn', None) or getattr(
settings, 'LDAP_GROUPS_BASE', None
)
description = getattr(instance, '_ldap_description', None)
scope = getattr(instance, '_ldap_scope', 'global')
security_enabled = getattr(instance, '_ldap_security_enabled', True)
try:
svc.group_create(
cn=instance.name,
parent_dn=parent_dn,
description=description,
scope=scope,
security_enabled=security_enabled,
)
logger.info(f"Created LDAP group: {instance.name}")
except Exception as e:
logger.error(f"Failed to create LDAP group {instance.name}: {e}", exc_info=True)
# Не прерываем - группа уже создана в БД
else:
# Обновление существующей группы
dn = _resolve_group_dn(instance)
if not dn:
logger.debug(f"Group {instance.id} has no LDAP DN, skipping sync")
return
# Проверяем, было ли переименование
old_name = getattr(instance, '_ldap_old_name', None)
if old_name and old_name != instance.name:
try:
new_dn = svc.group_rename(dn, instance.name)
# Обновляем DN в LdapSyncState
sync_state = LdapSyncState.objects.filter(
model="group", object_pk=str(instance.pk)
).first()
if sync_state:
sync_state.ldap_dn = new_dn
sync_state.save(update_fields=['ldap_dn'])
dn = new_dn
logger.info(f"Renamed LDAP group from {old_name} to {instance.name}")
except Exception as e:
logger.error(f"Failed to rename LDAP group {old_name}: {e}", exc_info=True)
# Проверяем, было ли изменение описания
new_description = getattr(instance, '_ldap_description', '__NO_CHANGE__')
if new_description != '__NO_CHANGE__':
try:
svc.group_set_description(dn, new_description or None)
logger.info(f"Updated description for LDAP group {instance.name}")
except Exception as e:
logger.error(f"Failed to set description for LDAP group {instance.name}: {e}", exc_info=True)
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
logger.error(
f"LDAP sync failed for Group {instance.id} (created={created}): {e}",
exc_info=True
)
except Exception as e:
logger.error(
f"Unexpected error in LDAP sync for Group {instance.id}: {e}",
exc_info=True
)
finally:
# Очищаем временные атрибуты
for attr in ['_skip_ldap_sync', '_ldap_parent_dn', '_ldap_description',
'_ldap_scope', '_ldap_security_enabled', '_ldap_old_name']:
if hasattr(instance, attr):
delattr(instance, attr)
@receiver(post_delete, sender=Group)
def sync_group_to_ldap_on_delete(sender, instance, **kwargs):
"""Удаляет LDAP группу при удалении Group."""
if not _is_ldap_enabled():
return
try:
dn = _resolve_group_dn(instance)
if not dn:
logger.debug(f"Group {instance.id} has no LDAP DN, skipping delete")
return
svc = DirectoryService()
svc.group_delete(dn)
logger.info(f"Deleted LDAP group: {instance.name}")
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
logger.error(
f"LDAP delete failed for Group {instance.id}: {e}",
exc_info=True
)
except Exception as e:
logger.error(
f"Unexpected error in LDAP delete for Group {instance.id}: {e}",
exc_info=True
)
@receiver(m2m_changed, sender=Group.user_set.through)
def sync_group_members_to_ldap(sender, instance, action, pk_set, **kwargs):
"""Синхронизирует изменения участников группы с LDAP.
Реагирует на post_add, post_remove, post_clear для синхронизации member в LdapGroup.
"""
if not _is_ldap_enabled():
return
if action not in ['post_add', 'post_remove', 'post_clear']:
return
if getattr(instance, '_skip_ldap_sync', False):
return
try:
dn = _resolve_group_dn(instance)
if not dn:
logger.debug(f"Group {instance.id} has no LDAP DN, skipping member sync")
return
svc = DirectoryService()
if action == 'post_add' and pk_set:
# Добавление участников
from employees.models import Employee
users = Employee.objects.filter(pk__in=pk_set, is_ldap_managed=True)
if not users:
return
member_dns = []
for user in users:
sync_state = LdapSyncState.objects.filter(
model='employee', object_pk=str(user.pk)
).first()
if sync_state and sync_state.ldap_dn:
member_dns.append(sync_state.ldap_dn)
if member_dns:
try:
svc.group_add_members(dn, member_dns)
logger.info(f"Added {len(member_dns)} members to LDAP group {instance.name}")
except Exception as e:
logger.error(f"Failed to add members to LDAP group {instance.name}: {e}", exc_info=True)
elif action == 'post_remove' and pk_set:
# Удаление участников
from employees.models import Employee
users = Employee.objects.filter(pk__in=pk_set, is_ldap_managed=True)
if not users:
return
member_dns = []
for user in users:
sync_state = LdapSyncState.objects.filter(
model='employee', object_pk=str(user.pk)
).first()
if sync_state and sync_state.ldap_dn:
member_dns.append(sync_state.ldap_dn)
if member_dns:
try:
svc.group_remove_members(dn, member_dns)
logger.info(f"Removed {len(member_dns)} members from LDAP group {instance.name}")
except Exception as e:
logger.error(f"Failed to remove members from LDAP group {instance.name}: {e}", exc_info=True)
elif action == 'post_clear':
# Очистка всех участников
try:
svc.group_replace_members(dn, [])
logger.info(f"Cleared all members from LDAP group {instance.name}")
except Exception as e:
logger.error(f"Failed to clear members from LDAP group {instance.name}: {e}", exc_info=True)
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
logger.error(
f"LDAP member sync failed for Group {instance.id}: {e}",
exc_info=True
)
except Exception as e:
logger.error(
f"Unexpected error in LDAP member sync for Group {instance.id}: {e}",
exc_info=True
)