3286 lines
132 KiB
Python
3286 lines
132 KiB
Python
# backend/api/v1/employees/views.py
|
||
from __future__ import annotations
|
||
|
||
import traceback
|
||
from datetime import timedelta
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from common.emails import send_templated_mail
|
||
from django.conf import settings
|
||
from django.contrib.auth import get_user_model
|
||
from django.contrib.auth.models import Group, Permission
|
||
from django.core.exceptions import FieldError
|
||
from django.core.files.base import ContentFile
|
||
from django.db import transaction
|
||
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef,
|
||
Prefetch, Q, Subquery, Value, When)
|
||
from django.db.models.functions import Coalesce
|
||
from django.shortcuts import get_object_or_404
|
||
from django.utils import timezone
|
||
from django.utils.crypto import get_random_string
|
||
from employees.constants import ACTION_DISMISSED
|
||
from employees.ldap.directory_service import (DirectoryDepartmentDTO,
|
||
DirectoryService,
|
||
DirectoryUserDTO)
|
||
from employees.ldap.errors import (DirectoryDbError, DirectoryLdapError,
|
||
DirectoryServiceError)
|
||
from employees.ldap.infrastructure.connections import _conn
|
||
from employees.models import (Department, DepartmentPermission, DepartmentRole,
|
||
DeptPerm, EmployeeAction, EmployeeDepartment,
|
||
LdapSyncState, Position, Skill)
|
||
from employees.utils import (_build_links_for_dept, _detect_phone_field,
|
||
_ensure_department_permissions,
|
||
_head_choices_for_dept, _normalize_phone,
|
||
_perm_choices_synced, _to_bool,
|
||
_validate_head_active)
|
||
from rest_framework import filters, status, viewsets
|
||
from rest_framework.decorators import action
|
||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.views import APIView
|
||
|
||
from ..permissions import (AdminOrActionOrModelPerms, AdminOrDeptAllowed,
|
||
IsSelfOrStaff, has_dept_perm)
|
||
from .serializers import (AddMemberInput, DepartmentBriefSerializer,
|
||
DepartmentRoleSerializer, DepartmentSerializer,
|
||
EmailSerializer, EmailVerifySerializer,
|
||
EmployeeActionSerializer, EmployeeBriefSerializer,
|
||
EmployeeListSerializer, EmployeeSerializer,
|
||
GroupSerializer, PositionSerializer,
|
||
RegisterSerializer, RemoveMemberInput, SetHeadInput,
|
||
SetMemberRoleInput, SkillSerializer)
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
PHONE_FIELD = _detect_phone_field()
|
||
|
||
|
||
def _is_ldap_enabled():
|
||
"""Проверяет, включена ли интеграция с LDAP."""
|
||
return getattr(settings, "LDAP_ENABLED", False)
|
||
|
||
|
||
def _ldap_try(fn):
|
||
"""Выполняет функцию, которая работает с LDAP, и обрабатывает ошибки.
|
||
|
||
Если LDAP отключен, функция не выполняется и возвращается None.
|
||
"""
|
||
if not _is_ldap_enabled():
|
||
return None
|
||
|
||
try:
|
||
fn()
|
||
return None
|
||
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
|
||
return Response(
|
||
{"detail": f"LDAP sync failed: {e}"}, status=status.HTTP_502_BAD_GATEWAY
|
||
)
|
||
|
||
|
||
def _with_ldap_service(operation_name="LDAP operation"):
|
||
"""Декоратор для методов, которые используют DirectoryService.
|
||
|
||
Пропускает выполнение LDAP-операций если LDAP отключен.
|
||
При ошибках LDAP возвращает Response с кодом 502.
|
||
|
||
Args:
|
||
operation_name: Название операции для логирования
|
||
"""
|
||
def decorator(func):
|
||
def wrapper(*args, **kwargs):
|
||
if not _is_ldap_enabled():
|
||
# LDAP отключен - пропускаем операцию
|
||
return None
|
||
|
||
try:
|
||
return func(*args, **kwargs)
|
||
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
|
||
return Response(
|
||
{"detail": f"{operation_name} failed: {e}"},
|
||
status=status.HTTP_502_BAD_GATEWAY
|
||
)
|
||
return wrapper
|
||
return decorator
|
||
|
||
|
||
class HistoryActionMixin:
|
||
"""
|
||
Добавляет GET /{basename}/{pk}/history/
|
||
Параметры (необяз.): ?from=ISO ?to=ISO ?user=<id|email> ?type=+|~|-
|
||
"""
|
||
|
||
history_diff_fields = None # список полей для diff; если None — попытаемся угадать
|
||
|
||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
|
||
def history(self, request, pk=None):
|
||
obj = self.get_object()
|
||
qs = obj.history.select_related("history_user").order_by(
|
||
"-history_date", "-history_id"
|
||
)
|
||
|
||
q_from = request.query_params.get("from")
|
||
q_to = request.query_params.get("to")
|
||
q_user = request.query_params.get("user")
|
||
q_type = request.query_params.get("type")
|
||
|
||
if q_from:
|
||
try:
|
||
qs = qs.filter(history_date__gte=q_from)
|
||
except:
|
||
pass
|
||
if q_to:
|
||
try:
|
||
qs = qs.filter(history_date__lte=q_to)
|
||
except:
|
||
pass
|
||
if q_user:
|
||
qs = qs.filter(
|
||
Q(history_user__id__iexact=q_user)
|
||
| Q(history_user__email__iexact=q_user)
|
||
)
|
||
if q_type in {"+", "~", "-"}:
|
||
qs = qs.filter(history_type=q_type)
|
||
|
||
items = list(qs)
|
||
results = []
|
||
for i, cur in enumerate(items):
|
||
prev = items[i + 1] if i + 1 < len(items) else None
|
||
changes = {}
|
||
# какие поля сравнивать
|
||
if self.history_diff_fields is not None:
|
||
fields = self.history_diff_fields
|
||
else:
|
||
# берём только реальные field.name модели (без M2M)
|
||
fields = [f.name for f in obj._meta.fields]
|
||
|
||
for name in fields:
|
||
new = getattr(cur, name, None)
|
||
old = getattr(prev, name, None) if prev else None
|
||
if old != new:
|
||
changes[name] = {"old": old, "new": new}
|
||
|
||
results.append(
|
||
{
|
||
"history_id": cur.history_id,
|
||
"history_date": cur.history_date,
|
||
"history_type": cur.history_type, # "+", "~", "-"
|
||
"history_user": getattr(cur.history_user, "email", None),
|
||
"changes": changes,
|
||
}
|
||
)
|
||
return Response({"count": len(results), "results": results})
|
||
|
||
|
||
class ResendEmailAPIView(APIView):
|
||
"""POST /api/v1/auth/resend-email/ body: {"email": "..."}"""
|
||
|
||
throttle_scope = "anon"
|
||
permission_classes = [AllowAny]
|
||
|
||
def post(self, request):
|
||
ser = EmailSerializer(data=request.data)
|
||
ser.is_valid(raise_exception=True)
|
||
email = ser.validated_data["email"]
|
||
|
||
user = Employee.objects.filter(email__iexact=email).first()
|
||
if not user:
|
||
return Response({"ok": False, "error": "user_not_found"}, status=404)
|
||
if user.email_verified:
|
||
return Response({"ok": False, "error": "already_verified"}, status=400)
|
||
|
||
user.email_activation_code = get_random_string(6, "0123456789")
|
||
user.save(update_fields=["email_activation_code"])
|
||
|
||
send_templated_mail(
|
||
subject="Подтверждение регистрации",
|
||
to=[user.email],
|
||
template_base="emails/registration_verify_code",
|
||
context={"code": user.email_activation_code, "user": user},
|
||
)
|
||
return Response({"ok": True}, status=200)
|
||
|
||
|
||
class VerifyEmailAPIView(APIView):
|
||
throttle_scope = "anon"
|
||
permission_classes = [AllowAny]
|
||
|
||
def post(self, request):
|
||
"""Подтверждает email и активирует пользователя.
|
||
|
||
В режиме с LDAP активирует запись в LDAP.
|
||
В режиме без LDAP просто активирует пользователя в БД.
|
||
"""
|
||
ser = EmailVerifySerializer(data=request.data)
|
||
ser.is_valid(raise_exception=True)
|
||
email = ser.validated_data["email"].strip().lower()
|
||
code = ser.validated_data["code"].strip()
|
||
|
||
user = Employee.objects.filter(email__iexact=email).first()
|
||
if not user:
|
||
return Response({"ok": False, "error": "user_not_found"}, status=404)
|
||
if not code:
|
||
return Response({"ok": False, "error": "empty_code"}, status=400)
|
||
|
||
created = getattr(user, "created_at", None) or getattr(
|
||
user, "date_joined", None
|
||
)
|
||
if created and not user.email_verified:
|
||
if timezone.now() - created > timedelta(minutes=5):
|
||
user.delete()
|
||
return Response({"ok": False, "error": "expired"}, status=400)
|
||
|
||
if not user.verify_email(code):
|
||
return Response({"ok": False, "error": "invalid_code"}, status=400)
|
||
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# Режим с LDAP: активируем запись в LDAP
|
||
try:
|
||
from employees.models import LdapSyncState
|
||
svc = DirectoryService()
|
||
|
||
# Проверяем наличие LDAP-идентификаторов в LdapSyncState
|
||
sync_state = LdapSyncState.objects.filter(
|
||
model="employee",
|
||
object_pk=str(user.pk)
|
||
).first()
|
||
|
||
has_ldap = sync_state and (sync_state.ldap_dn or sync_state.ldap_guid)
|
||
|
||
if has_ldap:
|
||
# Запись существует - активируем через DirectoryService
|
||
user = svc.update_user(user, {"is_active": True})
|
||
else:
|
||
# LDAP запись не найдена - активируем только в БД
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.warning(
|
||
f"User {email} has no LDAP sync state, activating in DB only"
|
||
)
|
||
user.is_active = True
|
||
user.save(update_fields=["is_active"])
|
||
|
||
# Убеждаемся, что БД тоже активна
|
||
if not user.is_active:
|
||
user.is_active = True
|
||
user.save(update_fields=["is_active"])
|
||
except DirectoryLdapError as e:
|
||
# Если LDAP недоступен, всё равно активируем в БД
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.warning(
|
||
f"LDAP error during activation for {email}: {e}, activating in DB"
|
||
)
|
||
user.is_active = True
|
||
user.save(update_fields=["is_active"])
|
||
except DirectoryDbError as e:
|
||
return Response(
|
||
{"ok": False, "error": "db_error", "detail": str(e)}, status=500
|
||
)
|
||
except DirectoryServiceError as e:
|
||
# При ошибке сервиса тоже активируем в БД
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.warning(
|
||
f"Service error during activation for {email}: {e}, activating in DB"
|
||
)
|
||
user.is_active = True
|
||
user.save(update_fields=["is_active"])
|
||
else:
|
||
# Режим без LDAP: просто активируем пользователя в БД
|
||
if not user.is_active:
|
||
user.is_active = True
|
||
user.save(update_fields=["is_active"])
|
||
|
||
return Response({"ok": True, "user_id": user.id}, status=200)
|
||
|
||
|
||
class RegisterAPIView(APIView):
|
||
"""Регистрация: создаём учётку в LDAP (disabled) с паролем, в БД — set_unusable_password."""
|
||
|
||
throttle_scope = "anon"
|
||
permission_classes = [AllowAny]
|
||
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
||
|
||
@transaction.atomic
|
||
def post(self, request):
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Логируем входящие данные для диагностики
|
||
logger.warning(f"[REGISTER] Received data: {request.data}")
|
||
logger.warning(f"[REGISTER] Content-Type: {request.content_type}")
|
||
|
||
# 0) хотя бы один контакт
|
||
if not (
|
||
request.data.get("telegram")
|
||
or request.data.get("whatsapp")
|
||
or request.data.get("wechat")
|
||
):
|
||
logger.warning("[REGISTER] No contact provided")
|
||
return Response(
|
||
{
|
||
"detail": "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
|
||
},
|
||
status=400,
|
||
)
|
||
|
||
ser = RegisterSerializer(data=request.data)
|
||
if not ser.is_valid():
|
||
logger.warning(f"[REGISTER] Validation errors: {ser.errors}")
|
||
ser.is_valid(raise_exception=True)
|
||
v = ser.validated_data
|
||
|
||
email = v["email"].strip().lower()
|
||
password = v["password"]
|
||
phone_norm = _normalize_phone(
|
||
v.get("phone_number") or request.data.get("phone")
|
||
)
|
||
if not phone_norm:
|
||
return Response({"ok": False, "error": "invalid_phone"}, status=400)
|
||
|
||
if Employee.objects.filter(phone_number=phone_norm).exists():
|
||
return Response({"ok": False, "error": "phone_taken"}, status=400)
|
||
|
||
user = Employee.objects.filter(email__iexact=email).first()
|
||
if user:
|
||
if user.email_verified:
|
||
# Email уже верифицирован - нельзя регистрироваться
|
||
return Response({"ok": False, "error": "email_taken"}, status=400)
|
||
else:
|
||
# Есть неверифицированный пользователь - повторная отправка кода
|
||
return Response(
|
||
{"ok": True, "pending_verification": True, "user_id": user.id},
|
||
status=200,
|
||
)
|
||
|
||
avatar_file = v.get("avatar") or getattr(request, "FILES", {}).get("avatar")
|
||
avatar_bytes = None
|
||
avatar_name = None
|
||
if avatar_file:
|
||
try:
|
||
avatar_bytes = (
|
||
avatar_file.read() if hasattr(avatar_file, "read") else None
|
||
)
|
||
avatar_name = getattr(avatar_file, "name", None) or "avatar.jpg"
|
||
except Exception:
|
||
avatar_bytes = None
|
||
avatar_name = None
|
||
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# Режим с LDAP: создаём disabled учётку в LDAP + пароль
|
||
svc = DirectoryService()
|
||
dto = DirectoryUserDTO(
|
||
first_name=v["first_name"],
|
||
last_name=v["last_name"],
|
||
email=email,
|
||
phone_e164=phone_norm,
|
||
department_dn=None,
|
||
group_cns=[],
|
||
initial_password=password, # пароль идёт только в LDAP
|
||
avatar_bytes=avatar_bytes,
|
||
is_active=False, # disabled до верификации
|
||
)
|
||
try:
|
||
emp = svc.create_user(dto)
|
||
except DirectoryLdapError as e:
|
||
return Response(
|
||
{"ok": False, "error": "ldap_error", "detail": str(e)}, status=502
|
||
)
|
||
except DirectoryDbError as e:
|
||
return Response(
|
||
{"ok": False, "error": "db_error", "detail": str(e)}, status=500
|
||
)
|
||
else:
|
||
# Режим без LDAP: создаём пользователя напрямую в БД
|
||
emp = Employee.objects.create(
|
||
first_name=v["first_name"],
|
||
last_name=v["last_name"],
|
||
email=email,
|
||
phone_number=phone_norm,
|
||
is_active=False, # не активен до верификации email
|
||
is_ldap_managed=False,
|
||
)
|
||
# Устанавливаем пароль в БД
|
||
emp.set_password(password)
|
||
|
||
# 2) Заполняем доп.поля БД
|
||
if avatar_bytes:
|
||
try:
|
||
emp.avatar.save(avatar_name, ContentFile(avatar_bytes), save=False)
|
||
except Exception:
|
||
pass
|
||
|
||
emp.telegram = v.get("telegram", "")
|
||
emp.whatsapp = v.get("whatsapp", "")
|
||
emp.wechat = v.get("wechat", "")
|
||
emp.birth_date = v["birth_date"]
|
||
|
||
if v.get("patronymic"):
|
||
emp.patronymic = v["patronymic"]
|
||
if v.get("gender") is not None:
|
||
emp.gender = v["gender"]
|
||
|
||
pos_id = v.get("position")
|
||
if pos_id and Position.objects.filter(pk=pos_id).exists():
|
||
emp.position_id = pos_id
|
||
|
||
emp.save()
|
||
|
||
skills_ids = v.get("skills") or []
|
||
if skills_ids:
|
||
emp.skills.set(Skill.objects.filter(pk__in=skills_ids))
|
||
|
||
# 3) Отправляем код верификации
|
||
emp.email_activation_code = get_random_string(6, "0123456789")
|
||
emp.save(update_fields=["email_activation_code"])
|
||
send_templated_mail(
|
||
subject="Подтверждение регистрации",
|
||
to=[emp.email],
|
||
template_base="emails/registration_verify_code",
|
||
context={"code": emp.email_activation_code, "user": emp},
|
||
)
|
||
|
||
return Response(
|
||
{
|
||
"id": emp.id,
|
||
"email": emp.email,
|
||
"email_verified": emp.email_verified,
|
||
"is_active": emp.is_active,
|
||
},
|
||
status=status.HTTP_201_CREATED,
|
||
)
|
||
|
||
|
||
class DepartmentViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
CRUD отделов + действия:
|
||
- POST /departments/{id}/set_head
|
||
- POST /departments/{id}/set_member_role
|
||
- POST /departments/{id}/add_member
|
||
- POST /departments/{id}/remove_member
|
||
Права:
|
||
- update/partial_update/destroy → manage_department
|
||
- set_head → change_department_head
|
||
- set_member_role → assign_department_role (назначение/снятие роли)
|
||
- add_member/remove_member → manage_department (управление участниками)
|
||
- create → staff/superuser
|
||
- чтение → аутентифицированным
|
||
"""
|
||
|
||
queryset = Department.objects.select_related("head").prefetch_related("roles").all()
|
||
serializer_class = DepartmentSerializer
|
||
|
||
# пермишены (скоуп-право по отделу)
|
||
class ManagePerm(AdminOrDeptAllowed):
|
||
"""Право на общее управление отделом."""
|
||
|
||
required_code = DeptPerm.MANAGE
|
||
|
||
class ChangeHeadPerm(AdminOrDeptAllowed):
|
||
"""Право на смену руководителя отдела."""
|
||
|
||
required_code = DeptPerm.CHANGE_HEAD
|
||
|
||
class AssignRolePerm(AdminOrDeptAllowed):
|
||
"""Право на назначение ролей участникам отдела."""
|
||
|
||
required_code = DeptPerm.ASSIGN_ROLE
|
||
|
||
def get_permissions(self):
|
||
if self.action in {"update", "partial_update", "destroy"}:
|
||
return [self.ManagePerm()]
|
||
if self.action == "set_head":
|
||
return [self.ChangeHeadPerm()]
|
||
if self.action in {"set_member_role"}:
|
||
return [self.AssignRolePerm()]
|
||
if self.action in {"add_member", "remove_member"}:
|
||
return [self.ManagePerm()]
|
||
if self.action == "create":
|
||
return [AdminOrActionOrModelPerms()]
|
||
if self.action in {
|
||
"members",
|
||
"user_perms",
|
||
"ui_context",
|
||
"list",
|
||
"retrieve",
|
||
"my_departments",
|
||
}:
|
||
return [IsAuthenticated()]
|
||
return [AdminOrActionOrModelPerms()]
|
||
|
||
# --- поиск и сортировка, как в старой реализации/тестах ---
|
||
ordering = ["name"]
|
||
ordering_fields = ["name", "id"]
|
||
|
||
def get_queryset(self):
|
||
qs = super().get_queryset()
|
||
|
||
# ---------- аннотации для employees_count ----------
|
||
# Активные линкы данного отдела
|
||
active_links = EmployeeDepartment.objects.filter(
|
||
department_id=OuterRef("pk"), is_active=True
|
||
)
|
||
|
||
# Подсчёт distinct сотрудников по активным линкам
|
||
active_count_subq = (
|
||
active_links.values("department_id")
|
||
.annotate(c=Count("employee_id", distinct=True))
|
||
.values("c")[:1]
|
||
)
|
||
|
||
qs = qs.annotate(
|
||
active_count=Coalesce(
|
||
Subquery(active_count_subq, output_field=IntegerField()),
|
||
Value(0),
|
||
),
|
||
head_in_active=Exists(active_links.filter(employee_id=OuterRef("head_id"))),
|
||
).annotate(
|
||
employees_count=F("active_count")
|
||
+ Case(
|
||
When(
|
||
Q(head_id__isnull=False) & Q(head_in_active=False),
|
||
then=Value(1),
|
||
),
|
||
default=Value(0),
|
||
output_field=IntegerField(),
|
||
)
|
||
)
|
||
# ---------- /аннотации ----------
|
||
|
||
# Поиск и сортировка — как и раньше
|
||
search = (
|
||
self.request.query_params.get("search")
|
||
or self.request.query_params.get("q")
|
||
or ""
|
||
).strip()
|
||
if search:
|
||
qs = qs.filter(
|
||
Q(name__icontains=search)
|
||
| Q(description__icontains=search)
|
||
| Q(head__first_name__icontains=search)
|
||
| Q(head__last_name__icontains=search)
|
||
| Q(head__patronymic__icontains=search)
|
||
).distinct()
|
||
|
||
ordering = self.request.query_params.get("ordering")
|
||
if ordering in {"name", "-name", "id", "-id"}:
|
||
qs = qs.order_by(ordering)
|
||
else:
|
||
qs = qs.order_by("name")
|
||
return qs
|
||
|
||
# --- частичное изменение: изменение head требует отдельного кода ---
|
||
|
||
def partial_update(self, request, *args, **kwargs) -> Response:
|
||
"""Частичный апдейт отдела через сервисный слой (LDAP → DB).
|
||
|
||
Логика:
|
||
• Если меняется head — сперва права + валидация, затем DirectoryService.set_head().
|
||
• Поля name/description — через DirectoryService.update_department().
|
||
• Не вызываем super().partial_update() во избежание двойной записи.
|
||
|
||
Args:
|
||
request: DRF Request.
|
||
*args: Прочие позиционные аргументы.
|
||
**kwargs: Прочие именованные аргументы.
|
||
|
||
Returns:
|
||
Response: Сериализованные данные отдела после применения изменений.
|
||
|
||
Raises:
|
||
(внутренне обрабатываются и мапятся в HTTP-коды)
|
||
"""
|
||
instance = self.get_object()
|
||
data: Dict[str, Any] = request.data
|
||
ldap_enabled = _is_ldap_enabled()
|
||
svc = DirectoryService() if ldap_enabled else None
|
||
print(
|
||
"[DEBUG:partial_update] start",
|
||
{
|
||
"dept_id": instance.id,
|
||
"dept_name": instance.name,
|
||
"current_head_id": instance.head_id,
|
||
"raw_keys": list(data.keys()),
|
||
"raw_payload": dict(data),
|
||
"ldap_enabled": ldap_enabled,
|
||
},
|
||
)
|
||
|
||
# --- HEAD ---
|
||
if any(k in data for k in ("head", "head_id")):
|
||
raw_desired = data.get("head_id", data.get("head", None))
|
||
|
||
try:
|
||
if raw_desired is None:
|
||
pass
|
||
if isinstance(raw_desired, str) and raw_desired.strip().lower() in {
|
||
"",
|
||
"null",
|
||
"none",
|
||
}:
|
||
pass
|
||
try:
|
||
desired_head_id = int(raw_desired)
|
||
except (TypeError, ValueError):
|
||
raise ValueError("head_id должен быть целым числом или null")
|
||
except ValueError as e:
|
||
print("[ERROR:partial_update] parse head_id failed:", repr(e))
|
||
return Response(
|
||
{"head_id": [str(e)]}, status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
print(
|
||
"[DEBUG:partial_update] head branch",
|
||
{
|
||
"desired_head_id": desired_head_id,
|
||
"current_head_id": instance.head_id,
|
||
},
|
||
)
|
||
|
||
# --- права ---
|
||
perm = self.ChangeHeadPerm()
|
||
has_perm = perm.has_permission(
|
||
request, self
|
||
) and perm.has_object_permission(request, self, instance)
|
||
print(
|
||
"[DEBUG:partial_update] perm check:",
|
||
has_perm,
|
||
"user_id=",
|
||
getattr(request.user, "id", None),
|
||
)
|
||
if not has_perm:
|
||
print("[ERROR:partial_update] forbidden head change")
|
||
return Response(
|
||
{"detail": "Forbidden"}, status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Если указали того же руководителя — пропустим лишнюю работу
|
||
if (desired_head_id or None) == (instance.head_id or None):
|
||
pass # идем дальше — возможно пришли name/description
|
||
else:
|
||
# --- валидация кандидата ---
|
||
new_head = None
|
||
if desired_head_id is not None:
|
||
employee_model = (
|
||
type(instance)._meta.get_field("head").remote_field.model
|
||
)
|
||
new_head = get_object_or_404(employee_model, id=desired_head_id)
|
||
|
||
# Требовать verified, если новый head — не сам текущий head-пользователь
|
||
require_verified = not (
|
||
instance.head_id
|
||
and instance.head_id == getattr(request.user, "id", None)
|
||
)
|
||
ok, errs = _validate_head_active(
|
||
instance,
|
||
desired_head_id,
|
||
require_email_verified=require_verified,
|
||
)
|
||
print(
|
||
"[DEBUG:partial_update] head candidate validation:",
|
||
ok,
|
||
errs if not ok else None,
|
||
)
|
||
if not ok:
|
||
return Response(errs, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# --- LDAP → DB ---
|
||
if ldap_enabled:
|
||
# Режим с LDAP: синхронизируем через DirectoryService
|
||
try:
|
||
print(
|
||
"[DEBUG:partial_update] calling DirectoryService.set_head",
|
||
{
|
||
"dept_id": instance.id,
|
||
"new_head_id": getattr(new_head, "id", None),
|
||
},
|
||
)
|
||
instance = svc.set_head(instance, new_head)
|
||
print(
|
||
"[DEBUG:partial_update] set_head OK",
|
||
{
|
||
"result_head_id": instance.head_id,
|
||
"head_appointed_at": getattr(
|
||
instance, "head_appointed_at", None
|
||
),
|
||
},
|
||
)
|
||
except (
|
||
DirectoryLdapError,
|
||
DirectoryDbError,
|
||
DirectoryServiceError,
|
||
) as e:
|
||
code = (
|
||
status.HTTP_502_BAD_GATEWAY
|
||
if isinstance(e, DirectoryLdapError)
|
||
else (
|
||
status.HTTP_400_BAD_REQUEST
|
||
if isinstance(e, DirectoryServiceError)
|
||
else status.HTTP_500_INTERNAL_SERVER_ERROR
|
||
)
|
||
)
|
||
print(
|
||
"[ERROR:partial_update] set_head FAILED:",
|
||
{
|
||
"exc_type": type(e).__name__,
|
||
"status": code,
|
||
"message": str(e),
|
||
"trace": traceback.format_exc(),
|
||
},
|
||
)
|
||
return Response({"detail": str(e)}, status=code)
|
||
else:
|
||
# Режим без LDAP: обновляем напрямую в БД
|
||
instance.head = new_head
|
||
if new_head:
|
||
instance.head_appointed_at = timezone.now()
|
||
else:
|
||
instance.head_appointed_at = None
|
||
instance.save(update_fields=["head", "head_appointed_at"])
|
||
print("[DEBUG:partial_update] set_head OK (no LDAP)")
|
||
|
||
# --- NAME / DESCRIPTION ---
|
||
changes: Dict[str, Any] = {}
|
||
for k in ("name", "description"):
|
||
if k in data:
|
||
changes[k] = data.get(k)
|
||
|
||
print("[DEBUG:partial_update] changes branch:", changes)
|
||
if changes:
|
||
if ldap_enabled:
|
||
# Режим с LDAP: синхронизируем через DirectoryService
|
||
try:
|
||
print(
|
||
"[DEBUG:partial_update] calling DirectoryService.update_department",
|
||
{
|
||
"dept_id": instance.id,
|
||
"changes": changes,
|
||
},
|
||
)
|
||
instance = svc.update_department(instance, changes)
|
||
print("[DEBUG:partial_update] update_department OK")
|
||
except DirectoryLdapError as e:
|
||
print(
|
||
"[ERROR:partial_update] update_department LDAP FAILED:",
|
||
{"message": str(e), "trace": traceback.format_exc()},
|
||
)
|
||
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
||
except DirectoryDbError as e:
|
||
print(
|
||
"[ERROR:partial_update] update_department DB FAILED:",
|
||
{"message": str(e), "trace": traceback.format_exc()},
|
||
)
|
||
return Response(
|
||
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||
)
|
||
else:
|
||
# Режим без LDAP: обновляем напрямую в БД
|
||
for k, v in changes.items():
|
||
setattr(instance, k, v)
|
||
instance.save(update_fields=list(changes.keys()))
|
||
print("[DEBUG:partial_update] update_department OK (no LDAP)")
|
||
resp = self.get_serializer(instance).data
|
||
print("[DEBUG:partial_update] done OK, response keys:", list(resp.keys()))
|
||
return Response(self.get_serializer(instance).data, status=status.HTTP_200_OK)
|
||
|
||
# -------- actions --------
|
||
|
||
@action(detail=True, methods=["post"])
|
||
@transaction.atomic
|
||
def set_head(self, request, pk: str | None = None) -> Response:
|
||
"""Назначение/снятие руководителя отдела через сервисный слой (LDAP → DB).
|
||
|
||
Логика:
|
||
• Проверка прав через ChangeHeadPerm.
|
||
• Валидация кандидата (email_verified ослабляется для текущего head).
|
||
• Применение через DirectoryService.set_head().
|
||
|
||
Args:
|
||
request: DRF Request.
|
||
pk: Идентификатор отдела из URL.
|
||
|
||
Returns:
|
||
Response: Данные отдела после операции.
|
||
|
||
Raises:
|
||
ValidationError: При невалидном вводе (поднимается сериализатором).
|
||
DirectoryServiceError: Бизнес-ошибка сервиса (400).
|
||
DirectoryLdapError: Ошибка взаимодействия с LDAP (502).
|
||
DirectoryDbError: Ошибка записи в БД (500).
|
||
"""
|
||
print("[DEBUG:set_head] start pk=", pk, "raw_data=", request.data)
|
||
dept = self.get_object()
|
||
print(
|
||
"[DEBUG:set_head] dept:",
|
||
{"id": dept.id, "name": dept.name, "current_head_id": dept.head_id},
|
||
)
|
||
|
||
payload = SetHeadInput(data=request.data)
|
||
payload.is_valid(raise_exception=True)
|
||
head_id = payload.validated_data.get("head_id")
|
||
print("[DEBUG:set_head] validated head_id=", head_id)
|
||
|
||
employee_model = Department._meta.get_field("head").remote_field.model
|
||
new_head = (
|
||
get_object_or_404(employee_model, id=head_id)
|
||
if head_id is not None
|
||
else None
|
||
)
|
||
print(
|
||
"[DEBUG:set_head] resolved new_head:",
|
||
(
|
||
None
|
||
if new_head is None
|
||
else {"id": new_head.id, "email": getattr(new_head, "email", None)}
|
||
),
|
||
)
|
||
|
||
# --- права ---
|
||
print("[DEBUG:set_head] checking permissions via ChangeHeadPerm")
|
||
perm = self.ChangeHeadPerm()
|
||
has_perm = perm.has_permission(request, self) and perm.has_object_permission(
|
||
request, self, dept
|
||
)
|
||
print(
|
||
"[DEBUG:set_head] permissions:",
|
||
has_perm,
|
||
"user_id=",
|
||
getattr(request.user, "id", None),
|
||
)
|
||
if not has_perm:
|
||
print("[ERROR:set_head] permission denied")
|
||
return Response({"detail": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
|
||
|
||
# --- валидация кандидата ---
|
||
if head_id is not None:
|
||
require_verified = not (
|
||
dept.head_id and dept.head_id == getattr(request.user, "id", None)
|
||
)
|
||
print(
|
||
"[DEBUG:set_head] validating candidate:",
|
||
{"candidate_id": head_id, "require_email_verified": require_verified},
|
||
)
|
||
ok, errs = _validate_head_active(
|
||
dept, head_id, require_email_verified=require_verified
|
||
)
|
||
print(
|
||
"[DEBUG:set_head] validation result:",
|
||
ok,
|
||
"errors:",
|
||
errs if not ok else None,
|
||
)
|
||
if not ok:
|
||
print("[ERROR:set_head] candidate validation failed")
|
||
return Response(errs, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# --- применение ---
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# Режим с LDAP: применяем через DirectoryService
|
||
svc = DirectoryService()
|
||
try:
|
||
print(
|
||
"[DEBUG:set_head] applying via DirectoryService.set_head:",
|
||
{
|
||
"dept_id": dept.id,
|
||
"old_head_id": dept.head_id,
|
||
"new_head_id": getattr(new_head, "id", None),
|
||
},
|
||
)
|
||
dept = svc.set_head(dept, new_head)
|
||
print(
|
||
"[DEBUG:set_head] apply OK:",
|
||
{
|
||
"dept_id": dept.id,
|
||
"result_head_id": dept.head_id,
|
||
"head_appointed_at": getattr(dept, "head_appointed_at", None),
|
||
},
|
||
)
|
||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||
status_code = (
|
||
status.HTTP_502_BAD_GATEWAY
|
||
if isinstance(e, DirectoryLdapError)
|
||
else (
|
||
status.HTTP_400_BAD_REQUEST
|
||
if isinstance(e, DirectoryServiceError)
|
||
else status.HTTP_500_INTERNAL_SERVER_ERROR
|
||
)
|
||
)
|
||
print(
|
||
"[ERROR:set_head] apply FAILED:",
|
||
{"type": type(e).__name__, "status": status_code, "message": str(e)},
|
||
)
|
||
return Response({"detail": str(e)}, status=status_code)
|
||
else:
|
||
# Режим без LDAP: обновляем напрямую в БД
|
||
dept.head = new_head
|
||
if new_head:
|
||
dept.head_appointed_at = timezone.now()
|
||
else:
|
||
dept.head_appointed_at = None
|
||
dept.save(update_fields=["head", "head_appointed_at"])
|
||
print("[DEBUG:set_head] apply OK (no LDAP)")
|
||
|
||
resp = self.get_serializer(dept).data
|
||
print(
|
||
"[DEBUG:set_head] response payload prepared:", {"keys": list(resp.keys())}
|
||
)
|
||
return Response(resp, status=status.HTTP_200_OK)
|
||
|
||
@action(detail=True, methods=["post"])
|
||
@transaction.atomic
|
||
def set_member_role(self, request, pk=None):
|
||
"""
|
||
Назначает/снимает РОЛЬ существующему участнику отдела.
|
||
Не создаёт членство и не меняет is_active.
|
||
|
||
Тело:
|
||
{ "employee_id": <int>, "role_id": <int|null> }
|
||
Права:
|
||
AssignRolePerm (DeptPerm.ASSIGN_ROLE)
|
||
Ответ:
|
||
200 {"employee_id":..., "role_id": <int|null>, "is_active": <bool>}
|
||
404 если сотрудник не состоит в отделе
|
||
400 если роль не принадлежит отделу
|
||
"""
|
||
dept = self.get_object()
|
||
payload = SetMemberRoleInput(data=request.data)
|
||
if not payload.is_valid():
|
||
return Response(payload.errors, status=400)
|
||
|
||
emp_id = payload.validated_data["employee_id"]
|
||
role_id = payload.validated_data.get("role_id")
|
||
|
||
# проверяем роль, если указана, что она принадлежит этому отделу
|
||
employee_model = Department._meta.get_field("head").remote_field.model
|
||
employee = get_object_or_404(employee_model, id=emp_id)
|
||
role = None
|
||
if role_id is not None:
|
||
role = get_object_or_404(DepartmentRole, id=role_id)
|
||
if role.department_id != dept.id:
|
||
return Response(
|
||
{"role_id": ["Role does not belong to this department."]},
|
||
status=400,
|
||
)
|
||
|
||
svc = DirectoryService() if _is_ldap_enabled() else None
|
||
|
||
if svc:
|
||
# Режим с LDAP: синхронизируем роли
|
||
try:
|
||
# внутри: проверяет наличие линка, меняет роль, (опц.) LDAP-группу
|
||
svc.set_member_role(dept, employee, role)
|
||
link = EmployeeDepartment.objects.get(
|
||
employee_id=emp_id, department_id=dept.id
|
||
)
|
||
except EmployeeDepartment.DoesNotExist:
|
||
return Response(
|
||
{"detail": "Employee is not a member of this department."}, status=404
|
||
)
|
||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||
return Response(
|
||
{"detail": str(e)},
|
||
status=(
|
||
502
|
||
if isinstance(e, DirectoryLdapError)
|
||
else 400 if isinstance(e, DirectoryServiceError) else 500
|
||
),
|
||
)
|
||
else:
|
||
# Режим без LDAP: просто обновляем роль в линке
|
||
try:
|
||
link = EmployeeDepartment.objects.get(
|
||
employee_id=emp_id, department_id=dept.id
|
||
)
|
||
link.role = role
|
||
link.save(update_fields=["role"])
|
||
except EmployeeDepartment.DoesNotExist:
|
||
return Response(
|
||
{"detail": "Employee is not a member of this department."}, status=404
|
||
)
|
||
|
||
return Response(
|
||
{
|
||
"employee_id": emp_id,
|
||
"role_id": (role.id if role else None),
|
||
"is_active": link.is_active,
|
||
},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
|
||
@action(detail=True, methods=["get"], url_path="members")
|
||
def members(self, request, pk=None):
|
||
"""
|
||
GET /api/v1/departments/{id}/members/
|
||
Список участников отдела в формате, удобном для фронта-шаблона.
|
||
"""
|
||
dept = self.get_object()
|
||
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
|
||
return Response({"count": len(links), "results": links}, status=200)
|
||
|
||
@action(detail=True, methods=["get"], url_path="user-perms")
|
||
def user_perms(self, request, pk=None):
|
||
"""
|
||
GET /api/v1/departments/{id}/user-perms/
|
||
Флаги прав текущего пользователя в рамках отдела.
|
||
"""
|
||
dept = self.get_object()
|
||
uid = getattr(request.user, "id", None)
|
||
data = {
|
||
"is_head": bool(uid and uid == dept.head_id),
|
||
"can_manage": has_dept_perm(request.user, dept.id, DeptPerm.MANAGE),
|
||
"can_change_head": has_dept_perm(
|
||
request.user, dept.id, DeptPerm.CHANGE_HEAD
|
||
),
|
||
"can_assign_roles": has_dept_perm(
|
||
request.user, dept.id, DeptPerm.ASSIGN_ROLE
|
||
),
|
||
}
|
||
return Response(data, status=200)
|
||
|
||
@action(detail=True, methods=["get"], url_path="ui-context")
|
||
def ui_context(self, request, pk=None):
|
||
"""
|
||
GET /api/v1/departments/{id}/ui-context/
|
||
BFF-агрегатор для страницы отдела: возвращает все необходимые данные одной пачкой.
|
||
Поля:
|
||
- dept: DepartmentSerializer
|
||
- roles: DepartmentRoleSerializer[]
|
||
- links: см. /members
|
||
- head_choices: [{id,name}]
|
||
- dept_perm_choices: [{id,code,name}]
|
||
- user_perms: {is_head, can_manage, can_change_head, can_assign_roles}
|
||
"""
|
||
dept = self.get_object()
|
||
dept_data = self.get_serializer(dept).data
|
||
|
||
roles_qs = (
|
||
DepartmentRole.objects.filter(department_id=dept.id)
|
||
.prefetch_related("scoped_permissions")
|
||
.order_by("name", "id")
|
||
)
|
||
roles_data = DepartmentRoleSerializer(roles_qs, many=True).data
|
||
|
||
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
|
||
perm_choices = _perm_choices_synced()
|
||
head_choices = _head_choices_for_dept(dept, EmployeeBriefSerializer)
|
||
user_perms = {
|
||
"is_head": (
|
||
request.user.id == dept.head_id
|
||
if getattr(request.user, "id", None)
|
||
else False
|
||
),
|
||
"can_manage": has_dept_perm(request.user, dept.id, DeptPerm.MANAGE),
|
||
"can_change_head": has_dept_perm(
|
||
request.user, dept.id, DeptPerm.CHANGE_HEAD
|
||
),
|
||
"can_assign_roles": has_dept_perm(
|
||
request.user, dept.id, DeptPerm.ASSIGN_ROLE
|
||
),
|
||
}
|
||
|
||
payload = {
|
||
"dept": dept_data,
|
||
"roles": roles_data,
|
||
"links": links,
|
||
"head_choices": head_choices,
|
||
"dept_perm_choices": perm_choices,
|
||
"user_perms": user_perms,
|
||
}
|
||
return Response(payload, status=200)
|
||
|
||
@action(detail=True, methods=["post"], url_path="add_member")
|
||
@transaction.atomic
|
||
def add_member(self, request, pk: int | None = None):
|
||
"""Добавляет сотрудника в отдел: MOVE в OU → активирует линк (LDAP → DB)."""
|
||
dept = self.get_object()
|
||
payload = AddMemberInput(data=request.data)
|
||
payload.is_valid(raise_exception=True)
|
||
|
||
emp_id = payload.validated_data["employee_id"]
|
||
|
||
employee_model = Department._meta.get_field("head").remote_field.model
|
||
employee = get_object_or_404(employee_model, id=emp_id)
|
||
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# Режим с LDAP: добавляем через DirectoryService
|
||
svc = DirectoryService()
|
||
try:
|
||
svc.add_member(dept, employee)
|
||
link = (
|
||
EmployeeDepartment.objects.filter(
|
||
employee_id=emp_id, department_id=dept.id
|
||
)
|
||
.only("role_id", "is_active")
|
||
.first()
|
||
)
|
||
|
||
return Response(
|
||
{
|
||
"employee_id": emp_id,
|
||
"is_active": True,
|
||
"role_id": getattr(link, "role_id", None) if link else None,
|
||
},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||
return Response(
|
||
{"detail": str(e)},
|
||
status=(
|
||
status.HTTP_502_BAD_GATEWAY
|
||
if isinstance(e, DirectoryLdapError)
|
||
else (
|
||
status.HTTP_400_BAD_REQUEST
|
||
if isinstance(e, DirectoryServiceError)
|
||
else status.HTTP_500_INTERNAL_SERVER_ERROR
|
||
)
|
||
),
|
||
)
|
||
else:
|
||
# Режим без LDAP: создаём/активируем линк напрямую
|
||
link, created = EmployeeDepartment.objects.get_or_create(
|
||
employee_id=emp_id,
|
||
department_id=dept.id,
|
||
defaults={"is_active": True}
|
||
)
|
||
if not created and not link.is_active:
|
||
link.is_active = True
|
||
link.save(update_fields=["is_active"])
|
||
|
||
return Response(
|
||
{
|
||
"employee_id": emp_id,
|
||
"is_active": True,
|
||
"role_id": link.role_id,
|
||
},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
|
||
@action(detail=True, methods=["post"], url_path="remove_member")
|
||
@transaction.atomic
|
||
def remove_member(self, request, pk: int | None = None):
|
||
"""Удаляет члена отдела: MOVE в Users OU → удаляет линк (LDAP → DB)."""
|
||
dept = self.get_object()
|
||
|
||
payload = RemoveMemberInput(data=request.data)
|
||
payload.is_valid(raise_exception=True)
|
||
emp_id: int = payload.validated_data["employee_id"]
|
||
|
||
employee_model = Department._meta.get_field("head").remote_field.model
|
||
employee = get_object_or_404(employee_model, id=emp_id)
|
||
# защита от удаления руководителя — ДО вызова сервиса
|
||
if dept.head_id == emp_id:
|
||
return Response(
|
||
{"detail": "Нельзя удалить руководителя отдела."},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# Режим с LDAP: удаляем через DirectoryService
|
||
svc = DirectoryService()
|
||
try:
|
||
svc.remove_member(dept, employee)
|
||
return Response(
|
||
{"employee_id": emp_id, "removed": True},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||
return Response(
|
||
{"detail": str(e)},
|
||
status=(
|
||
502
|
||
if isinstance(e, DirectoryLdapError)
|
||
else 400 if isinstance(e, DirectoryServiceError) else 500
|
||
),
|
||
)
|
||
else:
|
||
# Режим без LDAP: деактивируем линк напрямую
|
||
try:
|
||
link = EmployeeDepartment.objects.get(
|
||
employee_id=emp_id,
|
||
department_id=dept.id
|
||
)
|
||
link.is_active = False
|
||
link.save(update_fields=["is_active"])
|
||
return Response(
|
||
{"employee_id": emp_id, "removed": True},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
except EmployeeDepartment.DoesNotExist:
|
||
return Response(
|
||
{"detail": "Employee is not a member of this department."},
|
||
status=status.HTTP_404_NOT_FOUND,
|
||
)
|
||
|
||
@action(detail=False, methods=["get"], url_path="my-departments")
|
||
def my_departments(self, request) -> Response:
|
||
"""Вернёт список отделов, доступных текущему пользователю.
|
||
|
||
Логика:
|
||
Только отделы, где пользователь является руководителем
|
||
или имеет активную связь в ``EmployeeDepartment`` (is_active=True).
|
||
|
||
Args:
|
||
request: Текущий HTTP-запрос DRF.
|
||
|
||
Returns:
|
||
Response: JSON-массив отделов в формате ``DepartmentSerializer`` (many=True),
|
||
отсортированный по названию и id.
|
||
|
||
Raises:
|
||
NotAuthenticated: Если пользователь не авторизован (обрабатывается классом
|
||
разрешений в ``get_permissions``).
|
||
"""
|
||
user = request.user
|
||
qs = self.get_queryset()
|
||
|
||
active_link_exists = EmployeeDepartment.objects.filter(
|
||
department_id=OuterRef("pk"),
|
||
employee_id=user.id,
|
||
is_active=True,
|
||
)
|
||
user_qs = qs.filter(Q(head_id=user.id) | Exists(active_link_exists)).distinct()
|
||
|
||
user_qs = user_qs.order_by("name", "id")
|
||
data = DepartmentBriefSerializer(user_qs, many=True).data
|
||
return Response(data)
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание отдела: сначала LDAP OU → затем запись Department (если LDAP включен)."""
|
||
print("[DEBUG] Начало создания отдела. Данные запроса:", request.data)
|
||
ser = self.get_serializer(data=request.data)
|
||
ser.is_valid(raise_exception=True)
|
||
head = None
|
||
head_id = ser.validated_data.get("head") or ser.validated_data.get("head_id")
|
||
if head_id:
|
||
print("[DEBUG] Найден head_id:", head_id)
|
||
employee_model = Department._meta.get_field("head").remote_field.model
|
||
head = get_object_or_404(employee_model, id=head_id)
|
||
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# Режим с LDAP: создаём через DirectoryService
|
||
dto = DirectoryDepartmentDTO(
|
||
name=ser.validated_data["name"],
|
||
description=ser.validated_data.get("description", ""),
|
||
head=head,
|
||
)
|
||
svc = DirectoryService()
|
||
try:
|
||
print("[DEBUG] Создание отдела в DirectoryService:", dto)
|
||
dept = svc.create_department(dto)
|
||
print("[DEBUG] Отдел успешно создан:", dept)
|
||
return Response(
|
||
self.get_serializer(dept).data, status=status.HTTP_201_CREATED
|
||
)
|
||
except DirectoryLdapError as e:
|
||
print("[ERROR] Ошибка LDAP при создании отдела:", str(e))
|
||
return Response({"detail": str(e)}, status=502)
|
||
except DirectoryDbError as e:
|
||
print("[ERROR] Ошибка базы данных при создании отдела:", str(e))
|
||
return Response({"detail": str(e)}, status=500)
|
||
else:
|
||
# Режим без LDAP: создаём напрямую в БД
|
||
dept = Department.objects.create(
|
||
name=ser.validated_data["name"],
|
||
description=ser.validated_data.get("description", ""),
|
||
head=head,
|
||
)
|
||
print("[DEBUG] Отдел создан в БД (без LDAP):", dept)
|
||
return Response(
|
||
self.get_serializer(dept).data, status=status.HTTP_201_CREATED
|
||
)
|
||
|
||
def destroy(self, request, *args, **kwargs):
|
||
"""Удаляет отдел: сначала в LDAP → затем из БД (если LDAP включен)."""
|
||
print("[DEBUG] Начало удаления отдела.")
|
||
dept = self.get_object()
|
||
print("[DEBUG] Удаляемый отдел:", dept)
|
||
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# Режим с LDAP: удаляем через DirectoryService
|
||
svc = DirectoryService()
|
||
try:
|
||
print("[DEBUG] Удаление отдела в DirectoryService.")
|
||
svc.delete_department(dept)
|
||
print("[DEBUG] Отдел успешно удалён.")
|
||
return Response(status=204)
|
||
except DirectoryLdapError as e:
|
||
print("[ERROR] Ошибка LDAP при удалении отдела:", str(e))
|
||
return Response({"detail": str(e)}, status=502)
|
||
except DirectoryDbError as e:
|
||
print("[ERROR] Ошибка базы данных при удалении отдела:", str(e))
|
||
return Response({"detail": str(e)}, status=500)
|
||
else:
|
||
# Режим без LDAP: удаляем напрямую из БД
|
||
dept.delete()
|
||
print("[DEBUG] Отдел удалён из БД (без LDAP).")
|
||
return Response(status=204)
|
||
|
||
|
||
class EmployeeViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
/api/v1/employees/
|
||
|
||
Доступ:
|
||
- GET list/retrieve — только аутентифицированные пользователи.
|
||
- POST (create) — только staff/superuser (в методе create есть явная проверка).
|
||
- PATCH/PUT/DELETE {id} — staff/superuser ИЛИ пользователи с модельными правами
|
||
на Employee (через AdminOrActionOrModelPerms).
|
||
- GET/PATCH /employees/me/ — только аутентифицированные; PATCH правит профиль текущего пользователя.
|
||
- POST {id}/add_skill|remove_skill — требуется спец-пермишен
|
||
"employees.manage_employee_skills" ИЛИ staff/superuser
|
||
(через AdminOrActionOrModelPerms).
|
||
|
||
Поиск: last_name, first_name, patronymic, email, phone_number
|
||
|
||
Фильтры:
|
||
?department=<id> — участники отдела + head
|
||
?position=<id> — по должности
|
||
?skill=<id>&skill=<id> — любые из навыков (OR)
|
||
?email_verified=true|false
|
||
?active=true|false
|
||
?actually_active=true|false — реально активные: email_verified=True и не DISMISSED
|
||
(или нет действий, но is_active=True)
|
||
?created_at__gte=<iso_date> — зарегистрированные после указанной даты
|
||
|
||
Сортировка:
|
||
?ordering=last_name|first_name|created_at|id (+ '-' для DESC)
|
||
|
||
Примечания:
|
||
- В detail/retrieve и в me мы добавляем в контекст сериализатора флаги
|
||
include_actions и include_action_history — в ответе придут кадровые
|
||
события сотрудника и их история.
|
||
- Создание /employees/ доступно ТОЛЬКО staff/superuser, даже если у пользователя
|
||
есть модельный пермишен add_employee (это отмечено явно в create()).
|
||
"""
|
||
|
||
serializer_class = EmployeeSerializer
|
||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||
search_fields = ["last_name", "first_name", "patronymic", "email", "phone_number"]
|
||
ordering_fields = ["last_name", "first_name", "created_at", "id"]
|
||
ordering = ["last_name", "first_name"]
|
||
required_perms_by_action = {
|
||
"add_skill": "employees.manage_employee_skills",
|
||
"remove_skill": "employees.manage_employee_skills",
|
||
}
|
||
|
||
def get_permissions(self):
|
||
# create: только staff/superuser (перепроверяете и в методе create)
|
||
if self.action == "create":
|
||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||
|
||
# update/partial_update/destroy: владелец ИЛИ админ/модельные пермишены
|
||
if self.action in {
|
||
"update",
|
||
"partial_update",
|
||
"destroy",
|
||
"add_skill",
|
||
"remove_skill",
|
||
}:
|
||
return [IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)()]
|
||
|
||
# list / retrieve / me -> только аутентификация
|
||
return [IsAuthenticated()]
|
||
|
||
def get_queryset(self):
|
||
last_action_code_sq = Subquery(
|
||
EmployeeAction.objects.filter(employee_id=OuterRef("pk"))
|
||
.order_by("-date")
|
||
.values("action")[:1]
|
||
)
|
||
dep_links_prefetch = Prefetch(
|
||
"departments_links",
|
||
queryset=EmployeeDepartment.objects.filter(is_active=True).select_related(
|
||
"department", "role"
|
||
),
|
||
to_attr="dept_links",
|
||
)
|
||
|
||
prefetches = [
|
||
"skills",
|
||
dep_links_prefetch,
|
||
Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")),
|
||
]
|
||
|
||
qs = (
|
||
Employee.objects.select_related("position")
|
||
.prefetch_related(*prefetches)
|
||
.annotate(last_action_code=last_action_code_sq)
|
||
.order_by(*self.ordering)
|
||
)
|
||
|
||
qp = self.request.query_params
|
||
|
||
# по отделу: связи + руководитель
|
||
dep = qp.get("department")
|
||
if dep:
|
||
try:
|
||
dep_id = int(dep)
|
||
except (TypeError, ValueError):
|
||
dep_id = None
|
||
if dep_id:
|
||
member_ids = EmployeeDepartment.objects.filter(
|
||
department_id=dep_id
|
||
).values("employee_id")
|
||
head_ids = Department.objects.filter(id=dep_id).values("head_id")
|
||
qs = qs.filter(Q(id__in=member_ids) | Q(id__in=head_ids))
|
||
|
||
# по должности
|
||
position = qp.get("position")
|
||
if position:
|
||
qs = qs.filter(position_id=position)
|
||
|
||
# по навыкам (any-of)
|
||
skill_ids = qp.getlist("skill")
|
||
if skill_ids:
|
||
qs = qs.filter(skills__in=skill_ids).distinct()
|
||
|
||
# статусы
|
||
email_verified = _to_bool(qp.get("email_verified"))
|
||
if email_verified is not None:
|
||
qs = qs.filter(email_verified=email_verified)
|
||
|
||
active = _to_bool(qp.get("active"))
|
||
if active is not None:
|
||
qs = qs.filter(is_active=active)
|
||
|
||
# реально активен
|
||
actually = _to_bool(qp.get("actually_active"))
|
||
if actually is True:
|
||
qs = qs.filter(
|
||
Q(email_verified=True)
|
||
& (
|
||
Q(last_action_code__isnull=True, is_active=True)
|
||
| ~Q(last_action_code=ACTION_DISMISSED)
|
||
)
|
||
)
|
||
elif actually is False:
|
||
qs = qs.exclude(
|
||
Q(email_verified=True)
|
||
& (
|
||
Q(last_action_code__isnull=True, is_active=True)
|
||
| ~Q(last_action_code=ACTION_DISMISSED)
|
||
)
|
||
)
|
||
|
||
# фильтр по дате создания
|
||
created_at_gte = qp.get("created_at__gte")
|
||
if created_at_gte:
|
||
qs = qs.filter(created_at__gte=created_at_gte)
|
||
|
||
return qs
|
||
|
||
def get_serializer_class(self):
|
||
# для списка отдаём облегчённый, но с нужными полями
|
||
if self.action == "list":
|
||
return EmployeeListSerializer
|
||
return EmployeeSerializer
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание пользователя админом: пароль уходит ТОЛЬКО в LDAP, локальный — unusable."""
|
||
if not (request.user.is_staff or request.user.is_superuser):
|
||
return Response({"detail": "Only staff can create users."}, status=403)
|
||
|
||
ser = self.get_serializer(data=request.data)
|
||
ser.is_valid(raise_exception=True)
|
||
vd = dict(ser.validated_data)
|
||
|
||
if not (vd.get("whatsapp") or vd.get("telegram") or vd.get("wechat")):
|
||
return Response(
|
||
{
|
||
"detail": "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
|
||
},
|
||
status=400,
|
||
)
|
||
|
||
email = vd.pop("email", None)
|
||
phone_number = vd.pop("phone_number", None)
|
||
password = request.data.get("password")
|
||
if not email or not phone_number or not password:
|
||
return Response(
|
||
{"detail": "email, phone_number и password обязательны"}, status=400
|
||
)
|
||
|
||
avatar_bytes = None
|
||
avatar_file = vd.pop("avatar", None)
|
||
if avatar_file and hasattr(avatar_file, "read"):
|
||
try:
|
||
avatar_bytes = avatar_file.read()
|
||
except Exception:
|
||
avatar_bytes = None
|
||
|
||
# Проверяем, включен ли LDAP
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# Режим с LDAP: создаём через DirectoryService
|
||
dto = DirectoryUserDTO(
|
||
first_name=vd.get("first_name", ""),
|
||
last_name=vd.get("last_name", ""),
|
||
email=email.lower(),
|
||
phone_e164=phone_number,
|
||
department_dn=vd.pop("department_dn", None),
|
||
group_cns=vd.pop("group_cns", []) or [],
|
||
initial_password=password, # ← только LDAP
|
||
avatar_bytes=avatar_bytes,
|
||
is_active=vd.get("is_active", True),
|
||
)
|
||
|
||
try:
|
||
user = DirectoryService().create_user(dto)
|
||
# DB-only поля
|
||
for k in (
|
||
"patronymic",
|
||
"birth_date",
|
||
"telegram",
|
||
"whatsapp",
|
||
"wechat",
|
||
"position",
|
||
):
|
||
if k in vd:
|
||
setattr(user, k + ("_id" if k == "position" else ""), vd[k])
|
||
user.save()
|
||
if user.position_id:
|
||
try:
|
||
DirectoryService().assign_position(user, user.position)
|
||
except Exception:
|
||
# можно залогировать warning; создание пользователя не роняем
|
||
pass
|
||
skills = vd.get("skills_ids") or []
|
||
if skills:
|
||
user.skills.set(skills)
|
||
out = self.get_serializer(user)
|
||
return Response(
|
||
out.data, status=201, headers=self.get_success_headers(out.data)
|
||
)
|
||
except DirectoryLdapError as e:
|
||
return Response({"detail": str(e)}, status=502)
|
||
except DirectoryDbError as e:
|
||
return Response({"detail": str(e)}, status=500)
|
||
else:
|
||
# Режим без LDAP: создаём напрямую в БД
|
||
try:
|
||
with transaction.atomic():
|
||
user = Employee.objects.create(
|
||
first_name=vd.get("first_name", ""),
|
||
last_name=vd.get("last_name", ""),
|
||
email=email.lower(),
|
||
phone_number=phone_number,
|
||
is_active=vd.get("is_active", True),
|
||
is_ldap_managed=False,
|
||
)
|
||
# Устанавливаем пароль
|
||
user.set_password(password)
|
||
|
||
# DB-only поля
|
||
for k in (
|
||
"patronymic",
|
||
"birth_date",
|
||
"telegram",
|
||
"whatsapp",
|
||
"wechat",
|
||
"position",
|
||
"gender",
|
||
):
|
||
if k in vd:
|
||
setattr(user, k + ("_id" if k == "position" else ""), vd[k])
|
||
|
||
# Аватар
|
||
if avatar_bytes:
|
||
user.photo.save(
|
||
f"avatar_{user.id}.jpg",
|
||
ContentFile(avatar_bytes),
|
||
save=False,
|
||
)
|
||
|
||
user.save()
|
||
|
||
# Навыки
|
||
skills = vd.get("skills_ids") or []
|
||
if skills:
|
||
user.skills.set(skills)
|
||
|
||
out = self.get_serializer(user)
|
||
return Response(
|
||
out.data, status=201, headers=self.get_success_headers(out.data)
|
||
)
|
||
except Exception as e:
|
||
return Response(
|
||
{"detail": f"Ошибка создания пользователя: {str(e)}"},
|
||
status=500,
|
||
)
|
||
|
||
@action(detail=False, methods=["get", "patch"])
|
||
def me(self, request):
|
||
"""GET — профиль текущего пользователя; PATCH — частичное обновление своего профиля.
|
||
|
||
Returns:
|
||
Response: Данные профиля.
|
||
"""
|
||
instance: Employee = request.user # type: ignore
|
||
|
||
if request.method == "GET":
|
||
instance = (
|
||
Employee.objects.select_related("position")
|
||
.prefetch_related(
|
||
"skills",
|
||
Prefetch(
|
||
"departments_links",
|
||
queryset=EmployeeDepartment.objects.filter(
|
||
is_active=True
|
||
).select_related("department", "role"),
|
||
to_attr="dept_links",
|
||
),
|
||
Prefetch(
|
||
"actions", queryset=EmployeeAction.objects.order_by("-date")
|
||
),
|
||
)
|
||
.get(pk=request.user.pk)
|
||
)
|
||
ctx = self.get_serializer_context()
|
||
ctx["include_actions"] = True
|
||
ctx["include_action_history"] = True
|
||
data = self.get_serializer(instance, context=ctx).data
|
||
return Response(data, status=200)
|
||
|
||
# PATCH
|
||
old_email = instance.email # Сохраняем старый email
|
||
|
||
# Логируем для отладки
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.info(f"[ME PATCH] request.data keys: {list(request.data.keys())}")
|
||
logger.info(f"[ME PATCH] request.FILES keys: {list(request.FILES.keys())}")
|
||
logger.info(f"[ME PATCH] Content-Type: {request.content_type}")
|
||
if 'avatar' in request.data:
|
||
logger.info(f"[ME PATCH] avatar in request.data: {type(request.data['avatar'])}, length: {len(str(request.data['avatar'])) if request.data['avatar'] else 0}")
|
||
if 'avatar' in request.FILES:
|
||
logger.info(f"[ME PATCH] avatar in request.FILES: {request.FILES['avatar'].name}, size: {request.FILES['avatar'].size}")
|
||
|
||
ser = self.get_serializer(instance, data=request.data, partial=True)
|
||
ser.is_valid(raise_exception=True)
|
||
vd = dict(ser.validated_data)
|
||
|
||
# Проверяем изменение email
|
||
new_email = vd.get("email")
|
||
email_changed = new_email and new_email.lower() != old_email.lower()
|
||
|
||
# Бизнес-валидация каналов связи
|
||
if all(k not in vd for k in ("whatsapp", "telegram", "wechat")):
|
||
new_whatsapp = vd.get("whatsapp", instance.whatsapp)
|
||
new_telegram = vd.get("telegram", instance.telegram)
|
||
new_wechat = vd.get("wechat", instance.wechat)
|
||
if not (new_whatsapp or new_telegram or new_wechat):
|
||
return Response(
|
||
{
|
||
"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."
|
||
},
|
||
status=400,
|
||
)
|
||
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
# LDAP-часть
|
||
ldap_keys = {"first_name", "last_name", "email", "phone_number", "is_active"}
|
||
ldap_changes = {k: vd.pop(k) for k in list(vd.keys()) if k in ldap_keys}
|
||
|
||
svc_changes = dict(ldap_changes)
|
||
pos_key_present = ("position" in request.data) or (
|
||
"position_id" in request.data
|
||
)
|
||
if pos_key_present:
|
||
pos_raw = (
|
||
request.data.get("position")
|
||
if "position" in request.data
|
||
else request.data.get("position_id")
|
||
)
|
||
svc_changes["position"] = pos_raw # тут может быть None — так и надо
|
||
vd.pop("position", None)
|
||
vd.pop("position_id", None)
|
||
|
||
move_to_department_dn = request.data.get("department_dn")
|
||
group_cns = request.data.get("group_cns")
|
||
|
||
# Проверяем аватар в validated_data (base64) или в FILES (FormData)
|
||
avatar_file = ser.validated_data.get("avatar") or request.FILES.get("avatar")
|
||
if avatar_file and hasattr(avatar_file, "read"):
|
||
try:
|
||
svc_changes["avatar_bytes"] = avatar_file.read()
|
||
except Exception:
|
||
pass
|
||
|
||
if ldap_enabled and (svc_changes or move_to_department_dn or group_cns is not None):
|
||
# Режим с LDAP: обновляем через DirectoryService
|
||
svc = DirectoryService()
|
||
try:
|
||
instance = svc.update_user(
|
||
instance,
|
||
changes=svc_changes,
|
||
group_cns=group_cns if group_cns is not None else None,
|
||
move_to_department_dn=move_to_department_dn,
|
||
)
|
||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||
return Response(
|
||
{"detail": str(e)},
|
||
status=502 if isinstance(e, DirectoryLdapError) else 500,
|
||
)
|
||
elif not ldap_enabled and svc_changes:
|
||
# Режим без LDAP: обновляем напрямую в БД
|
||
for k, v in svc_changes.items():
|
||
if k != "position" and k != "avatar_bytes":
|
||
setattr(instance, k, v)
|
||
elif k == "position":
|
||
instance.position_id = v
|
||
elif k == "avatar_bytes" and v:
|
||
instance.avatar.save(
|
||
f"avatar_{instance.id}.jpg",
|
||
ContentFile(v),
|
||
save=False,
|
||
)
|
||
instance.save()
|
||
|
||
# DB-only
|
||
if vd:
|
||
ser_db = self.get_serializer(instance, data=vd, partial=True)
|
||
ser_db.is_valid(raise_exception=True)
|
||
ser_db.save()
|
||
instance = ser_db.instance
|
||
data = ser_db.data
|
||
else:
|
||
data = self.get_serializer(instance).data
|
||
|
||
# Сброс email_verified при изменении email
|
||
if email_changed:
|
||
from django.utils.crypto import get_random_string
|
||
from common.emails import send_templated_mail
|
||
|
||
instance.email_verified = False
|
||
instance.email_activation_code = get_random_string(6, "0123456789")
|
||
instance.save(update_fields=["email_verified", "email_activation_code"])
|
||
|
||
# Отправляем код на новый email
|
||
try:
|
||
send_templated_mail(
|
||
subject="Подтверждение нового email",
|
||
to=[instance.email],
|
||
template_base="emails/registration_verify_code",
|
||
context={"code": instance.email_activation_code, "user": instance},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# Обновляем данные в ответе
|
||
data["email_verified"] = False
|
||
|
||
return Response(data, status=200)
|
||
|
||
@action(detail=True, methods=["post"])
|
||
def add_skill(self, request, pk=None):
|
||
"""
|
||
body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }
|
||
"""
|
||
emp = self.get_object()
|
||
sid = request.data.get("skill_id")
|
||
sname = (request.data.get("skill_name") or "").strip()
|
||
|
||
sk = None
|
||
if sid:
|
||
sk = Skill.objects.filter(pk=sid).first()
|
||
if not sk and sname:
|
||
sk = Skill.objects.filter(
|
||
name__iexact=sname
|
||
).first() or Skill.objects.create(name=sname)
|
||
if not sk:
|
||
return Response({"detail": "Навык не найден/не указан"}, status=400)
|
||
|
||
emp.skills.add(sk)
|
||
return Response(
|
||
{"ok": True, "skill": {"id": sk.id, "name": sk.name}}, status=200
|
||
)
|
||
|
||
@action(detail=True, methods=["post"])
|
||
def remove_skill(self, request, pk=None):
|
||
"""
|
||
body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }
|
||
"""
|
||
emp = self.get_object()
|
||
sid = request.data.get("skill_id")
|
||
sname = (request.data.get("skill_name") or "").strip()
|
||
|
||
sk = None
|
||
if sid:
|
||
sk = Skill.objects.filter(pk=sid).first()
|
||
if not sk and sname:
|
||
sk = Skill.objects.filter(name__iexact=sname).first()
|
||
if not sk:
|
||
return Response({"detail": "Навык не найден"}, status=404)
|
||
|
||
emp.skills.remove(sk)
|
||
return Response(
|
||
{"ok": True, "removed": {"id": sk.id, "name": sk.name}}, status=200
|
||
)
|
||
|
||
def get_serializer_context(self):
|
||
ctx = super().get_serializer_context()
|
||
if getattr(self, "action", None) in {"retrieve", "me"}:
|
||
ctx["include_actions"] = True
|
||
ctx["include_action_history"] = True
|
||
return ctx
|
||
|
||
def partial_update(self, request, *args, **kwargs):
|
||
"""Частичное обновление: сначала LDAP-совместимые поля → затем только DB-only.
|
||
|
||
Returns:
|
||
Response: Обновлённые данные сотрудника.
|
||
"""
|
||
emp = self.get_object()
|
||
old_email = emp.email # Сохраняем старый email для проверки
|
||
|
||
ser = self.get_serializer(emp, data=request.data, partial=True)
|
||
ser.is_valid(raise_exception=True)
|
||
vd = dict(ser.validated_data)
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
# Проверяем изменение email
|
||
new_email = vd.get("email")
|
||
email_changed = new_email and new_email.lower() != old_email.lower()
|
||
|
||
# --- LDAP часть ---
|
||
ldap_keys = {"first_name", "last_name", "email", "phone_number", "is_active"}
|
||
ldap_changes = {k: vd.pop(k) for k in list(vd.keys()) if k in ldap_keys}
|
||
|
||
svc_changes = dict(ldap_changes)
|
||
pos_key_present = ("position" in request.data) or (
|
||
"position_id" in request.data
|
||
)
|
||
if pos_key_present:
|
||
pos_raw = (
|
||
request.data.get("position")
|
||
if "position" in request.data
|
||
else request.data.get("position_id")
|
||
)
|
||
svc_changes["position"] = pos_raw # тут может быть None — так и надо
|
||
vd.pop("position", None)
|
||
vd.pop("position_id", None)
|
||
|
||
move_to_department_dn = request.data.get("department_dn")
|
||
group_cns = request.data.get("group_cns") # список строк/None
|
||
|
||
# avatar → bytes (если разрешено править аватар в LDAP)
|
||
avatar_file = ser.validated_data.get("avatar")
|
||
if avatar_file and hasattr(avatar_file, "read"):
|
||
try:
|
||
svc_changes["avatar_bytes"] = avatar_file.read()
|
||
except Exception:
|
||
pass
|
||
|
||
if ldap_enabled:
|
||
# LDAP mode: sync with DirectoryService
|
||
svc = DirectoryService()
|
||
if svc_changes or move_to_department_dn or group_cns is not None:
|
||
try:
|
||
emp = svc.update_user(
|
||
emp,
|
||
changes=svc_changes,
|
||
group_cns=group_cns if group_cns is not None else None,
|
||
move_to_department_dn=move_to_department_dn,
|
||
)
|
||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||
return Response(
|
||
{"detail": str(e)},
|
||
status=502 if isinstance(e, DirectoryLdapError) else 500,
|
||
)
|
||
else:
|
||
# Non-LDAP mode: direct DB updates
|
||
# Restore ldap_changes back to vd for DB-only save
|
||
vd.update(ldap_changes)
|
||
|
||
# Handle position update in non-LDAP mode
|
||
if pos_key_present:
|
||
pos_raw = (
|
||
request.data.get("position")
|
||
if "position" in request.data
|
||
else request.data.get("position_id")
|
||
)
|
||
if pos_raw is None:
|
||
vd["position"] = None
|
||
elif isinstance(pos_raw, int):
|
||
try:
|
||
vd["position"] = Position.objects.get(id=pos_raw)
|
||
except Position.DoesNotExist:
|
||
return Response(
|
||
{"detail": f"Position {pos_raw} not found"}, status=400
|
||
)
|
||
elif isinstance(pos_raw, dict) and "id" in pos_raw:
|
||
try:
|
||
vd["position"] = Position.objects.get(id=pos_raw["id"])
|
||
except Position.DoesNotExist:
|
||
return Response(
|
||
{"detail": f"Position {pos_raw['id']} not found"}, status=400
|
||
)
|
||
|
||
# Handle avatar in non-LDAP mode
|
||
if avatar_file and hasattr(avatar_file, "read"):
|
||
try:
|
||
from django.core.files.base import ContentFile
|
||
avatar_bytes = avatar_file.read()
|
||
emp.avatar.save(avatar_file.name, ContentFile(avatar_bytes), save=False)
|
||
vd.pop("avatar", None) # Remove from vd as we handled it manually
|
||
except Exception:
|
||
pass
|
||
|
||
# --- DB-only часть ---
|
||
# Обновляем ТОЛЬКО оставшиеся поля, чтобы не перезаписать то, что уже сделал сервис
|
||
if vd:
|
||
ser_db = self.get_serializer(emp, data=vd, partial=True)
|
||
ser_db.is_valid(raise_exception=True)
|
||
ser_db.save()
|
||
emp = ser_db.instance
|
||
data = ser_db.data
|
||
else:
|
||
data = self.get_serializer(emp).data
|
||
|
||
# Сброс email_verified при изменении email
|
||
if email_changed:
|
||
from django.utils.crypto import get_random_string
|
||
from common.emails import send_templated_mail
|
||
|
||
emp.email_verified = False
|
||
emp.email_activation_code = get_random_string(6, "0123456789")
|
||
emp.save(update_fields=["email_verified", "email_activation_code"])
|
||
|
||
# Отправляем код на новый email
|
||
try:
|
||
send_templated_mail(
|
||
subject="Подтверждение нового email",
|
||
to=[emp.email],
|
||
template_base="emails/registration_verify_code",
|
||
context={"code": emp.email_activation_code, "user": emp},
|
||
)
|
||
except Exception:
|
||
# Не падаем если не удалось отправить письмо
|
||
pass
|
||
|
||
# Обновляем данные в ответе
|
||
data["email_verified"] = False
|
||
|
||
return Response(data, status=200)
|
||
|
||
|
||
class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||
"""
|
||
/api/v1/positions/
|
||
GET list/retrieve — аутентифицированные
|
||
POST/PUT/PATCH/DEL — staff/superuser ИЛИ пользователь с model perms
|
||
Экшены:
|
||
POST /{id}/set-groups
|
||
POST /{id}/add-groups
|
||
POST /{id}/remove-groups
|
||
GET /{id}/permissions
|
||
"""
|
||
|
||
queryset = Position.objects.all().prefetch_related("groups")
|
||
serializer_class = PositionSerializer
|
||
pagination_class = None
|
||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||
search_fields = ["name", "description"]
|
||
ordering_fields = ["name", "id"]
|
||
ordering = ["name"]
|
||
history_diff_fields = ["name", "description"]
|
||
permission_classes = [AdminOrActionOrModelPerms]
|
||
required_perms_by_action = {
|
||
"set_groups": "employees.assign_position_groups",
|
||
"add_groups": "employees.assign_position_groups",
|
||
"remove_groups": "employees.assign_position_groups",
|
||
}
|
||
|
||
def _validate_groups_payload(self, request):
|
||
ids = request.data.get("groups")
|
||
if not isinstance(ids, list):
|
||
return None, Response(
|
||
{"detail": "Поле 'groups' должно быть списком id"}, status=400
|
||
)
|
||
qs = Group.objects.filter(id__in=ids)
|
||
if qs.count() != len(set(ids)):
|
||
return None, Response({"detail": "Некоторые группы не найдены"}, status=400)
|
||
return qs, None
|
||
|
||
def get_permissions(self):
|
||
"""Возвращает пермишены для текущего экшена.
|
||
|
||
Returns:
|
||
list: Инスタнсы DRF-permission'ов.
|
||
"""
|
||
if self.action in {"list", "retrieve", "permissions"}:
|
||
return [IsAuthenticated()]
|
||
if self.action == "create":
|
||
return [AdminOrActionOrModelPerms()]
|
||
return [AdminOrActionOrModelPerms()]
|
||
|
||
@action(detail=True, methods=["post"])
|
||
def set_groups(self, request, pk=None):
|
||
pos = self.get_object()
|
||
qs, err = self._validate_groups_payload(request)
|
||
if err:
|
||
return err
|
||
pos.groups.set(qs)
|
||
err2 = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
|
||
if err2:
|
||
return err2
|
||
return Response(
|
||
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))}
|
||
)
|
||
|
||
@action(detail=True, methods=["post"])
|
||
def add_groups(self, request, pk=None):
|
||
pos = self.get_object()
|
||
qs, err = self._validate_groups_payload(request)
|
||
if err:
|
||
return err
|
||
pos.groups.add(*qs)
|
||
err2 = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
|
||
if err2:
|
||
return err2
|
||
return Response(
|
||
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))}
|
||
)
|
||
|
||
@action(detail=True, methods=["post"])
|
||
def remove_groups(self, request, pk=None):
|
||
pos = self.get_object()
|
||
qs, err = self._validate_groups_payload(request)
|
||
if err:
|
||
return err
|
||
pos.groups.remove(*qs)
|
||
err2 = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
|
||
if err2:
|
||
return err2
|
||
return Response(
|
||
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))}
|
||
)
|
||
|
||
@action(detail=True, methods=["get"])
|
||
def permissions(self, request, pk=None):
|
||
pos = self.get_object()
|
||
perms = (
|
||
Permission.objects.filter(group__positions=pos)
|
||
.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=200)
|
||
|
||
def perform_create(self, serializer):
|
||
pos = serializer.save()
|
||
err = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
|
||
if err:
|
||
# позиция создана в БД, но LDAP не синкнулся — возвращаем 502
|
||
raise Exception(
|
||
err.data["detail"]
|
||
) # или пропустите исключение, если не хотите рвать транзакцию
|
||
|
||
def perform_update(self, serializer):
|
||
pos = serializer.save()
|
||
err = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
|
||
if err:
|
||
# позиция обновлена в БД; LDAP можно синкнуть повторно позднее
|
||
pass
|
||
|
||
def perform_destroy(self, instance):
|
||
# best-effort: не блокируем удаление позиции из БД из-за LDAP
|
||
_ldap_try(lambda: DirectoryService().delete_position_group(instance))
|
||
return super().perform_destroy(instance)
|
||
|
||
|
||
class DepartmentRoleViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
Роли отдела:
|
||
- list/retrieve с фильтром ?department=<id>
|
||
- create/update/destroy → требуется право DeptPerm.ASSIGN_ROLE в рамках отдела роли
|
||
- GET /department-roles/perm_choices/
|
||
- GET /department-roles/{id}/perms/
|
||
- POST /department-roles/{id}/set_perms (ids или codes; полная замена)
|
||
"""
|
||
|
||
queryset = (
|
||
DepartmentRole.objects.select_related("department")
|
||
.prefetch_related("scoped_permissions")
|
||
.all()
|
||
)
|
||
serializer_class = DepartmentRoleSerializer
|
||
|
||
class AssignRolePerm(AdminOrDeptAllowed):
|
||
"""Право на назначение ролей участникам отдела."""
|
||
|
||
required_code = DeptPerm.ASSIGN_ROLE
|
||
|
||
# стабильная сортировка для листинга
|
||
ordering_fields = ("name", "id")
|
||
ordering = ("name", "id")
|
||
|
||
def get_permissions(self):
|
||
if self.action in {
|
||
"create",
|
||
"update",
|
||
"partial_update",
|
||
"destroy",
|
||
"set_perms",
|
||
}:
|
||
return [self.AssignRolePerm()]
|
||
return [IsAuthenticated()]
|
||
|
||
def get_queryset(self):
|
||
"""
|
||
Фильтрация по отделу и стабильный порядок.
|
||
"""
|
||
qs = super().get_queryset()
|
||
dept = self.request.query_params.get("department")
|
||
if dept:
|
||
qs = qs.filter(department_id=dept)
|
||
|
||
ord_param = self.request.query_params.get("ordering")
|
||
if ord_param in {"name", "-name", "id", "-id"}:
|
||
# стабильный тай-брейк по id
|
||
qs = qs.order_by(
|
||
ord_param, "id" if not ord_param.startswith("-") else "-id"
|
||
)
|
||
else:
|
||
qs = qs.order_by(*self.ordering)
|
||
return qs
|
||
|
||
@action(detail=False, methods=["get"])
|
||
def perm_choices(self, request):
|
||
"""
|
||
Возвращает справочник скоуп-прав для ролей отдела из DeptPerm.CHOICES.
|
||
Если каких-то записей нет в БД, создаёт их (идемпотентно).
|
||
"""
|
||
data = _ensure_department_permissions()
|
||
return Response({"count": len(data), "results": data}, status=200)
|
||
|
||
@action(detail=True, methods=["get"])
|
||
def perms(self, request, pk=None):
|
||
"""
|
||
Возвращает список прав, назначенных данной роли (отсортирован по коду).
|
||
"""
|
||
role = self.get_object()
|
||
data = [
|
||
{"id": p.id, "code": p.code, "name": p.name}
|
||
for p in role.scoped_permissions.order_by("code")
|
||
]
|
||
return Response({"count": len(data), "results": data}, status=200)
|
||
|
||
@action(detail=True, methods=["post"])
|
||
def set_perms(self, request, pk=None):
|
||
"""
|
||
Полностью заменяет набор прав у роли.
|
||
Тело запроса:
|
||
- либо "permission_ids": [1,2,3]
|
||
- либо "permission_codes": ["manage_department", ...]
|
||
"""
|
||
role = self.get_object()
|
||
|
||
ids = request.data.get("permission_ids") or []
|
||
codes = request.data.get("permission_codes") or []
|
||
|
||
if isinstance(ids, list) and ids:
|
||
# валидация набора id
|
||
ids_int = {int(i) for i in ids if str(i).isdigit()}
|
||
qs = DepartmentPermission.objects.filter(id__in=ids_int)
|
||
if qs.count() != len(ids_int):
|
||
return Response(
|
||
{"detail": "Некоторые permission_ids не найдены."}, status=400
|
||
)
|
||
elif isinstance(codes, list) and codes:
|
||
# валидация набора кодов
|
||
codes_set = set(codes)
|
||
qs = DepartmentPermission.objects.filter(code__in=codes_set)
|
||
if qs.count() != len(codes_set):
|
||
return Response(
|
||
{"detail": "Некоторые permission_codes не найдены."}, status=400
|
||
)
|
||
else:
|
||
qs = DepartmentPermission.objects.none()
|
||
|
||
role.scoped_permissions.set(list(qs))
|
||
ser = self.get_serializer(role)
|
||
return Response(ser.data, status=200)
|
||
|
||
|
||
class SkillViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
/api/v1/skills/
|
||
- GET list/retrieve — IsAuthenticated
|
||
- POST (create) — IsAuthenticated (любой авторизованный может создавать новые навыки)
|
||
- PATCH/PUT — staff/superuser ИЛИ perm employees.change_skill
|
||
- DELETE — staff/superuser ИЛИ perm employees.delete_skill
|
||
Экшены:
|
||
- POST /skills/bulk_create — как create (любой авторизованный)
|
||
- POST /skills/merge — объединить source->target (perm employees.change_skill)
|
||
body: {
|
||
"source_id": 1,
|
||
"target_id": 2,
|
||
"reassign": true, # переназначить сотрудникам source -> target (по умолч. true)
|
||
"delete_source": true # удалить исходный навык после переназначения (по умолч. true)
|
||
}
|
||
"""
|
||
|
||
queryset = Skill.objects.all().order_by("name")
|
||
serializer_class = SkillSerializer
|
||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||
search_fields = ["name"]
|
||
ordering_fields = ["name", "id"]
|
||
ordering = ["name"]
|
||
pagination_class = None
|
||
required_perms_by_action = {
|
||
"merge": "employees.change_skill",
|
||
}
|
||
|
||
def get_permissions(self):
|
||
if self.action in ("create", "bulk_create", "list", "retrieve"):
|
||
return [IsAuthenticated()]
|
||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||
|
||
@action(detail=False, methods=["post"])
|
||
def bulk_create(self, request):
|
||
names = request.data.get("names")
|
||
if not isinstance(names, list) or not names:
|
||
return Response(
|
||
{"detail": "Поле 'names' должно быть непустым списком строк"},
|
||
status=400,
|
||
)
|
||
|
||
# нормализация: trim + отбраковка пустых, дедуп (case-insensitive)
|
||
cleaned = []
|
||
seen = set()
|
||
for n in names:
|
||
if not isinstance(n, str):
|
||
continue
|
||
s = n.strip()
|
||
if not s:
|
||
continue
|
||
key = s.lower()
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
cleaned.append(s)
|
||
|
||
# отфильтруем уже существующие (по точному совпадению имени)
|
||
existing = set(
|
||
Skill.objects.filter(name__in=cleaned).values_list("name", flat=True)
|
||
)
|
||
to_create = [Skill(name=n) for n in cleaned if n not in existing]
|
||
Skill.objects.bulk_create(to_create, ignore_conflicts=True)
|
||
|
||
created = Skill.objects.filter(
|
||
name__in=[n for n in cleaned if n not in existing]
|
||
).order_by("name")
|
||
return Response(
|
||
{
|
||
"created_count": created.count(),
|
||
"created": SkillSerializer(created, many=True).data,
|
||
},
|
||
status=201,
|
||
)
|
||
|
||
@action(detail=False, methods=["post"])
|
||
@transaction.atomic
|
||
def merge(self, request):
|
||
"""
|
||
Заменяет навык source на target, опционально переносит всех сотрудников и удаляет source.
|
||
Требуется право employees.change_skill.
|
||
"""
|
||
sid = request.data.get("source_id")
|
||
tid = request.data.get("target_id")
|
||
reassign = bool(request.data.get("reassign", True))
|
||
delete_source = bool(request.data.get("delete_source", True))
|
||
|
||
if not sid or not tid:
|
||
return Response({"detail": "source_id и target_id обязательны"}, status=400)
|
||
try:
|
||
sid = int(sid)
|
||
tid = int(tid)
|
||
except (TypeError, ValueError):
|
||
return Response(
|
||
{"detail": "source_id и target_id должны быть числами"}, status=400
|
||
)
|
||
if sid == tid:
|
||
return Response(
|
||
{"detail": "source_id и target_id не должны совпадать"}, status=400
|
||
)
|
||
|
||
try:
|
||
source = Skill.objects.get(pk=sid)
|
||
target = Skill.objects.get(pk=tid)
|
||
except Skill.DoesNotExist:
|
||
return Response({"detail": "Skill не найден"}, status=404)
|
||
|
||
moved_count = 0
|
||
if reassign:
|
||
# переназначить всем сотрудникам: source -> target
|
||
qs = Employee.objects.filter(skills=source).only("id").distinct()
|
||
for emp in qs:
|
||
emp.skills.add(target)
|
||
emp.skills.remove(source)
|
||
moved_count += 1
|
||
|
||
# удалить исходный навык при необходимости
|
||
if delete_source:
|
||
source.delete()
|
||
|
||
return Response(
|
||
{
|
||
"ok": True,
|
||
"source_id": sid,
|
||
"target_id": tid,
|
||
"reassigned_employees": moved_count,
|
||
"source_deleted": bool(delete_source),
|
||
},
|
||
status=200,
|
||
)
|
||
|
||
|
||
class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||
"""
|
||
/api/v1/employee-actions/
|
||
- GET list/retrieve — IsAuthenticated
|
||
- POST create — staff/superuser ИЛИ perm employees.add_employeeaction
|
||
- PATCH/PUT update — staff/superuser ИЛИ perm employees.change_employeeaction
|
||
- DELETE destroy — staff/superuser ИЛИ perm employees.delete_employeeaction
|
||
|
||
Фильтры:
|
||
?employee=<id> — по сотруднику
|
||
?date_from=ISO — >=
|
||
?date_to=ISO — <=
|
||
"""
|
||
|
||
serializer_class = EmployeeActionSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
filter_backends = [filters.OrderingFilter]
|
||
ordering_fields = ["date", "id"]
|
||
ordering = ["-date"]
|
||
pagination_class = None
|
||
history_diff_fields = ["action", "date", "comment", "extra", "employee_id"]
|
||
|
||
def get_queryset(self):
|
||
qs = EmployeeAction.objects.select_related("employee").order_by(*self.ordering)
|
||
qp = self.request.query_params
|
||
emp = qp.get("employee")
|
||
if emp:
|
||
try:
|
||
emp_id = int(emp)
|
||
qs = qs.filter(employee_id=emp_id)
|
||
except (TypeError, ValueError):
|
||
return EmployeeAction.objects.none()
|
||
df = qp.get("date_from")
|
||
dt = qp.get("date_to")
|
||
if df:
|
||
try:
|
||
qs = qs.filter(date__gte=df)
|
||
except Exception:
|
||
pass
|
||
if dt:
|
||
try:
|
||
qs = qs.filter(date__lte=dt)
|
||
except Exception:
|
||
pass
|
||
return qs
|
||
|
||
# --- права на запись через модельные пермишены ---
|
||
def get_permissions(self):
|
||
if self.action == "create":
|
||
self.required_perm_code = "employees.add_employeeaction"
|
||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||
if self.action in ("update", "partial_update"):
|
||
self.required_perm_code = "employees.change_employeeaction"
|
||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||
if self.action == "destroy":
|
||
self.required_perm_code = "employees.delete_employeeaction"
|
||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||
self.required_perm_code = None
|
||
return super().get_permissions()
|
||
|
||
# --- бизнес-эффекты после записи события ---
|
||
def _apply_effects(self, action_obj: EmployeeAction):
|
||
emp = action_obj.employee
|
||
if action_obj.action == ACTION_DISMISSED:
|
||
# деактивируем сотрудника и связи
|
||
EmployeeDepartment.objects.filter(employee=emp, is_active=True).update(
|
||
is_active=False, date_to=timezone.now().date()
|
||
)
|
||
if emp.is_active:
|
||
emp.is_active = False
|
||
emp.save(update_fields=["is_active"])
|
||
else:
|
||
# любое иное событие делает сотрудника активным
|
||
if not emp.is_active:
|
||
emp.is_active = True
|
||
emp.save(update_fields=["is_active"])
|
||
|
||
@transaction.atomic
|
||
def perform_create(self, serializer):
|
||
obj = serializer.save()
|
||
self._apply_effects(obj)
|
||
|
||
@transaction.atomic
|
||
def perform_update(self, serializer):
|
||
obj = serializer.save()
|
||
self._apply_effects(obj)
|
||
|
||
|
||
class GroupViewSet(viewsets.ModelViewSet):
|
||
"""CRUD и LDAP-операции с группами.
|
||
|
||
Базовые маршруты:
|
||
GET /api/v1/groups/ — список (аутентифицированные)
|
||
GET /api/v1/groups/{id}/ — детальная
|
||
POST /api/v1/groups/ — создать (staff/superuser или с model perms)
|
||
PATCH /api/v1/groups/{id}/ — частичное обновление
|
||
DELETE /api/v1/groups/{id}/ — удалить
|
||
|
||
Экшены:
|
||
GET /api/v1/groups/{id}/permissions
|
||
POST /api/v1/groups/{id}/set-permissions
|
||
POST /api/v1/groups/{id}/add-permissions
|
||
POST /api/v1/groups/{id}/remove-permissions
|
||
|
||
POST /api/v1/groups/{id}/rename
|
||
POST /api/v1/groups/{id}/set-description
|
||
GET /api/v1/groups/{id}/members
|
||
POST /api/v1/groups/{id}/add-members
|
||
POST /api/v1/groups/{id}/remove-members
|
||
POST /api/v1/groups/{id}/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() # некорректный id → пусто
|
||
|
||
# У Group нет явного поля на пользователя; используется обратная связь ManyToMany от пользователя:
|
||
# group.user_set (manager). Для ORM-фильтров используется related_query_name — в django.contrib.auth он "user".
|
||
# На всякий случай поддержим оба варианта: user__ и user_set__ (если кастомная модель могла переопределить имя).
|
||
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.
|
||
|
||
Args:
|
||
request (Request): DRF Request с полем 'permissions' (list[int]).
|
||
|
||
Returns:
|
||
tuple[Optional[List[Permission]], Optional[Response]]: (список Permission или None, Response с 400 или None)
|
||
|
||
Raises:
|
||
TypeError: Если элементы 'permissions' не приводимы к int.
|
||
"""
|
||
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.
|
||
|
||
Сначала пробует DirectoryService.group_find_dn, затем (fallback) find_group_dn.
|
||
|
||
Args:
|
||
grp (Group): Экземпляр Group.
|
||
|
||
Returns:
|
||
Optional[str]: DN группы или None, если не найдена или LDAP отключен.
|
||
"""
|
||
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:
|
||
# совместимость со старым API
|
||
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).
|
||
|
||
Args:
|
||
payload (dict[str, Any]): Dict с ключами 'member_dns' (list[str]) и/или 'member_ids' (list[int]).
|
||
|
||
Returns:
|
||
list[str]: Уникальные DN (пустой список если LDAP отключен).
|
||
|
||
Raises:
|
||
ValueError: Если не удалось получить ни одного DN (только в LDAP режиме).
|
||
"""
|
||
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 [] # В non-LDAP режиме возвращаем пустой список
|
||
|
||
if not uniq:
|
||
raise ValueError("Не переданы корректные member_dns или member_ids")
|
||
return uniq
|
||
|
||
def _dns_to_users(self, dns: list[str]) -> list[Employee]:
|
||
"""Маппит DN участников на локальных пользователей через LdapSyncState.
|
||
|
||
Args:
|
||
dns (list[str]): Список DN.
|
||
|
||
Returns:
|
||
list[Employee]: Пользователи, найденные по LdapSyncState(model='employee', ldap_dn IN dns).
|
||
"""
|
||
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 в БД и (опционально) назначает permissions.
|
||
|
||
Body (частично):
|
||
name (str): CN группы (обязателен)
|
||
ldap_parent_dn (str): контейнер, по умолчанию settings.LDAP_GROUPS_BASE
|
||
ldap_description (str|None)
|
||
ldap_scope (str): 'global'|... (по умолчанию 'global')
|
||
ldap_security (bool): безопасная группа (по умолчанию True)
|
||
permissions (list[int]): список id Django permissions (необязателен)
|
||
|
||
Returns:
|
||
Response: 201 с сериализованной группой или ошибка 4xx/5xx.
|
||
|
||
Raises:
|
||
ValidationError: При ошибке сериализатора.
|
||
Exception: Пробрасывается как 502/500 в зависимости от источника ошибки.
|
||
"""
|
||
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:
|
||
# LDAP mode: create group in LDAP first
|
||
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)
|
||
|
||
# Create DB record
|
||
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:
|
||
# откат LDAP-создания
|
||
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), затем БД.
|
||
|
||
Body:
|
||
name (str): новое имя (опционально)
|
||
ldap_description (str|None): описание (опционально, "__NO_CHANGE__" — без изменений)
|
||
"""
|
||
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:
|
||
# LDAP mode: sync name/description with LDAP
|
||
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)
|
||
|
||
# Update DB
|
||
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 в БД.
|
||
|
||
Query params:
|
||
force_db (bool): При ошибке удаления в LDAP всё равно удалить запись БД.
|
||
"""
|
||
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, привязанные к группе (через M2M).
|
||
|
||
Returns:
|
||
Response: {"count": int, "results": [{id, codename, name, app, model}, ...]}
|
||
"""
|
||
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 у группы.
|
||
|
||
Body:
|
||
permissions (list[int]): список id разрешений.
|
||
"""
|
||
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 к группе (без удаления существующих).
|
||
|
||
Body:
|
||
permissions (list[int]): список id разрешений.
|
||
"""
|
||
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 у группы.
|
||
|
||
Body:
|
||
permissions (list[int]): список id разрешений.
|
||
"""
|
||
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-группу и синхронизирует имя в БД.
|
||
|
||
Body:
|
||
new_name (str): Новое имя (CN).
|
||
|
||
Returns:
|
||
Response: {"ok": True, "name": str}
|
||
"""
|
||
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)
|
||
|
||
# Update DB
|
||
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-группы.
|
||
|
||
Body:
|
||
description (str|None): Описание группы.
|
||
|
||
Returns:
|
||
Response: {"ok": True}
|
||
"""
|
||
grp = self.get_object()
|
||
|
||
if not _is_ldap_enabled():
|
||
# Non-LDAP mode: description stored only in LDAP, so just return OK
|
||
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-группы (DN) и краткую информацию о сопоставленных сотрудниках.
|
||
|
||
Returns:
|
||
Response: {"dns": list[str], "employees": list[dict]}
|
||
"""
|
||
grp = self.get_object()
|
||
|
||
if not _is_ldap_enabled():
|
||
# Non-LDAP mode: return DB members only
|
||
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-группу и связывает найденных пользователей в БД.
|
||
|
||
Body:
|
||
member_dns (list[str])|None: Прямые DN участников.
|
||
member_ids (list[int])|None: ID сотрудников, которые будут преобразованы в DN.
|
||
|
||
Returns:
|
||
Response: {"ok": True, "ldap_added": int, "db_added": int, "ok_dns": list[str], "ok_user_ids": list[int]}
|
||
|
||
Raises:
|
||
400: Некорректный payload.
|
||
404: Группа не найдена в LDAP.
|
||
502: Ошибка LDAP.
|
||
500: Ошибка БД (с попыткой компенсировать изменения в LDAP).
|
||
"""
|
||
grp = self.get_object()
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# LDAP mode: sync with LDAP first
|
||
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:
|
||
# компенсируем LDAP
|
||
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:
|
||
# Non-LDAP mode: add members directly to DB
|
||
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-группы и разрывает связи в БД.
|
||
|
||
Body:
|
||
member_dns (list[str])|None
|
||
member_ids (list[int])|None
|
||
|
||
Returns:
|
||
Response: {"ok": True, "ldap_removed": int, "db_removed": int, "ok_dns": list[str], "ok_user_ids": list[int]}
|
||
|
||
Errors:
|
||
400/404/502/500 — см. add_members.
|
||
"""
|
||
grp = self.get_object()
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# LDAP mode: sync with LDAP first
|
||
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:
|
||
# компенсируем LDAP
|
||
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:
|
||
# Non-LDAP mode: remove members directly from DB
|
||
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 в БД.
|
||
|
||
Body:
|
||
member_dns (list[str])|None
|
||
member_ids (list[int])|None
|
||
|
||
Returns:
|
||
Response: {"ok": True, "ldap_total": int, "db_total": int, "ok_dns": list[str], "ok_user_ids": list[int]}
|
||
|
||
Notes:
|
||
При ошибке БД выполняется попытка отката LDAP к исходному составу.
|
||
"""
|
||
grp = self.get_object()
|
||
ldap_enabled = _is_ldap_enabled()
|
||
|
||
if ldap_enabled:
|
||
# LDAP mode: sync with LDAP first
|
||
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:
|
||
# откат LDAP
|
||
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:
|
||
# Non-LDAP mode: replace members directly in DB
|
||
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,
|
||
)
|
||
|
||
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,
|
||
)
|