3286 lines
132 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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,
)