igor_nadein 19464a38cf refactor(employees): DRY duplicate logic in employees and departments
employees.py:
- Extracted _perform_user_update() method (180 lines)
- Simplified me PATCH: removed ~150 lines of duplicate code
- Simplified partial_update: removed ~155 lines of duplicate code
- Total reduction: ~255 lines (930 → 675)

departments.py:
- Extracted _perform_set_head() method (70 lines)
- Simplified set_head action: removed ~50 lines of duplicate code
- Simplified partial_update head branch: removed ~70 lines of duplicate code
- Total reduction: ~140 lines (752 → 612)

manage.py check: System check identified no issues
2026-02-16 16:32:12 +03:00

693 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""GroupViewSet — CRUD и LDAP-операции с группами."""
from __future__ import annotations
from typing import Any, 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 rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ...permissions import AdminOrActionOrModelPerms
from ..serializers import GroupSerializer
from ._helpers import Employee, _is_ldap_enabled
class GroupViewSet(viewsets.ModelViewSet):
"""CRUD и LDAP-операции с группами.
Базовые маршруты:
GET/POST /api/v1/groups/
GET/PATCH/DELETE /api/v1/groups/{id}/
Экшены: permissions, set-permissions, add-permissions, remove-permissions,
rename, set-description, members, add-members, remove-members, replace-members
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
pagination_class = None
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
ordering_fields = ["name", "id"]
ordering = ["name"]
permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms]
required_perms_by_action = {
"set_permissions": "employees.assign_group_permissions",
"add_permissions": "employees.assign_group_permissions",
"remove_permissions": "employees.assign_group_permissions",
"rename": "employees.assign_group_permissions",
"set_description": "employees.assign_group_permissions",
"add_members": "employees.assign_group_permissions",
"remove_members": "employees.assign_group_permissions",
"replace_members": "employees.assign_group_permissions",
}
# ---------- queryset ----------
def get_queryset(self):
qs = super().get_queryset()
member_raw = self.request.query_params.get(
"member"
) or self.request.query_params.get("member_id")
if member_raw is None:
return qs
try:
member_id = int(str(member_raw).strip())
except (TypeError, ValueError):
return qs.none()
try:
return qs.filter(
Q(user__id=member_id) | Q(user_set__id=member_id)
).distinct()
except FieldError:
try:
return qs.filter(user__id=member_id).distinct()
except FieldError:
try:
return qs.filter(user_set__id=member_id).distinct()
except FieldError:
return qs.none()
# ---------- helpers ----------
def _validate_permissions_payload(
self, request
) -> tuple[Optional[List[Permission]], Optional[Response]]:
"""Валидирует payload с ID permissions."""
ids = request.data.get("permissions")
if not isinstance(ids, list):
return None, Response(
{"detail": "Поле 'permissions' должно быть списком id"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
pid_list = [int(x) for x in ids]
except (TypeError, ValueError):
return None, Response(
{"detail": "Список 'permissions' должен содержать целые числа"},
status=status.HTTP_400_BAD_REQUEST,
)
qs = Permission.objects.filter(id__in=pid_list)
if qs.count() != len(set(pid_list)):
return None, Response(
{"detail": "Некоторые permissions не найдены"},
status=status.HTTP_400_BAD_REQUEST,
)
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 ----------
def create(self, request, *args, **kwargs) -> Response:
"""Создаёт LDAP-группу, затем запись Group в БД."""
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
)
out = self.get_serializer(grp)
return Response(
out.data,
status=status.HTTP_201_CREATED,
headers=self.get_success_headers(out.data),
)
def partial_update(self, request, *args, **kwargs) -> Response:
"""Частичное обновление: сначала LDAP (rename/description), затем БД."""
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)
if new_name and new_name != grp.name:
grp.name = new_name
grp.save(update_fields=["name"])
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
)
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 ----------
@action(detail=True, methods=["get"])
def permissions(self, request, pk=None) -> Response:
"""Permissions, привязанные к группе."""
grp = self.get_object()
perms = grp.permissions.select_related("content_type").distinct()
data = [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
"app": p.content_type.app_label,
"model": p.content_type.model,
}
for p in perms
]
return Response(
{"count": len(data), "results": data}, status=status.HTTP_200_OK
)
@action(detail=True, methods=["post"], url_path="set-permissions")
def set_permissions(self, request, pk=None) -> Response:
"""Полностью заменяет набор permissions у группы."""
grp = self.get_object()
qs, error = self._validate_permissions_payload(request)
if error:
return error
grp.permissions.set(qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"], url_path="add-permissions")
def add_permissions(self, request, pk=None) -> Response:
"""Добавляет permissions к группе."""
grp = self.get_object()
qs, error = self._validate_permissions_payload(request)
if error:
return error
grp.permissions.add(*qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"], url_path="remove-permissions")
def remove_permissions(self, request, pk=None) -> Response:
"""Удаляет указанные permissions у группы."""
grp = self.get_object()
qs, error = self._validate_permissions_payload(request)
if error:
return error
grp.permissions.remove(*qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=status.HTTP_200_OK,
)
# ---------- Actions: LDAP ----------
@action(detail=True, methods=["post"])
def rename(self, request, pk=None) -> Response:
"""Переименовывает LDAP-группу и синхронизирует имя в БД."""
grp = self.get_object()
new_name = (request.data.get("new_name") or "").strip()
if not new_name:
return Response(
{"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.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-группы."""
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)
@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)
@action(detail=True, methods=["post"], url_path="add-members")
def add_members(self, request, pk=None) -> Response:
"""Добавляет участников в 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,
)
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,
)
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,
)
@action(detail=True, methods=["post"], url_path="remove-members")
def remove_members(self, request, pk=None) -> Response:
"""Удаляет участников из 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,
)
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,
)
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,
)
@action(detail=True, methods=["post"], url_path="replace-members")
def replace_members(self, request, pk=None) -> Response:
"""Полностью заменяет состав LDAP-группы и синхронизирует M2M в БД."""
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,
)
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,
)
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,
)