diff --git a/backend/api/v1/employees/views/groups.py b/backend/api/v1/employees/views/groups.py index 9f91b216..62d954b0 100644 --- a/backend/api/v1/employees/views/groups.py +++ b/backend/api/v1/employees/views/groups.py @@ -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, + ) diff --git a/backend/employees/apps.py b/backend/employees/apps.py index d676dbcd..37d2908b 100644 --- a/backend/employees/apps.py +++ b/backend/employees/apps.py @@ -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: регистрация предикатов и правил доступа diff --git a/backend/employees/signals_group.py b/backend/employees/signals_group.py new file mode 100644 index 00000000..d374d754 --- /dev/null +++ b/backend/employees/signals_group.py @@ -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 + )