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:
parent
1bc6961b1d
commit
18ca956348
@ -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,
|
||||
)
|
||||
|
||||
@ -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: регистрация предикатов и правил доступа
|
||||
|
||||
241
backend/employees/signals_group.py
Normal file
241
backend/employees/signals_group.py
Normal 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
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user