Игорь Надеин 3e4137daa4 feat(ldap): добавлено кэширование LDAP логина с возможностью обновления
- Добавлено поле Employee.username для кэширования LDAP логина
- API endpoint обновлен: поддержка кэша и force_refresh параметра
- Новый дизайн: убран blur, добавлена иконка обновления
- Автоматическая загрузка при инициализации (из кэша или LDAP)
- Визуальная индикация источника данных (cached/fresh)
- Доступно всем пользователям без ограничений
2026-02-06 15:33:48 +03:00

4593 lines
187 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.

# flake8: noqa
from __future__ import annotations
import logging
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
# backend/api/v1/employees/views.py
logger = logging.getLogger(__name__)
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,
RoleAssignment,
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.exceptions import ValidationError
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)
existing_phone = Employee.objects.filter(phone_number=phone_norm).first()
if existing_phone:
logger.warning(
"[REGISTER] Phone %s already used by user id=%s",
phone_norm,
existing_phone.id,
)
return Response(
{
"ok": False,
"error": "phone_taken",
"detail": "Номер телефона уже зарегистрирован."
" Войдите в существующий аккаунт или используйте другой номер.",
"phone_number": [
"Этот номер телефона уже привязан к другому аккаунту."
],
},
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"]
emp.gender = v["gender"] # обязательное поле
if v.get("patronymic"):
emp.patronymic = v["patronymic"]
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):
"""
Назначает/снимает РОЛЬ сотруднику в контексте отдела.
Если сотрудник — член отдела, обновляет EmployeeDepartment.role.
Если нет — создаёт/обновляет RoleAssignment (новая логика: роли можно
назначать любому сотруднику компании).
Тело:
{ "employee_id": <int>, "role_id": <int|null> }
Права:
AssignRolePerm (DeptPerm.ASSIGN_ROLE)
Ответ:
200 {"employee_id":..., "role_id": <int|null>, "is_active": <bool>, "via_assignment": <bool>}
400 если роль не принадлежит отделу
"""
import logging
logger = logging.getLogger(__name__)
logger.warning(f"[set_member_role] request.data = {request.data}")
dept = self.get_object()
payload = SetMemberRoleInput(data=request.data)
if not payload.is_valid():
logger.warning(f"[set_member_role] validation errors = {payload.errors}")
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,
)
# Проверяем, является ли сотрудник членом отдела
link = EmployeeDepartment.objects.filter(
employee_id=emp_id, department_id=dept.id
).first()
logger.warning(
f"[set_member_role] emp={emp_id}, dept={dept.id}, role={role_id}, link={link}"
)
via_assignment = False
if link:
# Сотрудник — член отдела: обновляем роль в линке (старая логика)
svc = DirectoryService() if _is_ldap_enabled() else None
logger.warning(
f"[set_member_role] member branch, svc={type(svc).__name__ if svc else None}"
)
if svc:
try:
logger.warning(f"[set_member_role] calling svc.set_member_role")
svc.set_member_role(dept, employee, role)
logger.warning(f"[set_member_role] svc.set_member_role OK")
except (
DirectoryLdapError,
DirectoryDbError,
DirectoryServiceError,
) as e:
logger.error(
f"[set_member_role] svc.set_member_role FAILED: {type(e).__name__}: {e}"
)
import traceback
logger.error(
f"[set_member_role] traceback:\n{traceback.format_exc()}"
)
return Response(
{"detail": str(e)},
status=(
502
if isinstance(e, DirectoryLdapError)
else 400
if isinstance(e, DirectoryServiceError)
else 500
),
)
else:
link.role = role
link.save(update_fields=["role"])
# Также создаём/обновляем RoleAssignment для консистентности
if role:
RoleAssignment.objects.update_or_create(
employee_id=emp_id,
role=role,
defaults={"is_active": True, "assigned_by": request.user},
)
else:
# Снятие роли — деактивируем все назначения этой роли для сотрудника
RoleAssignment.objects.filter(
employee_id=emp_id, role__department=dept, is_active=True
).update(is_active=False)
else:
# Сотрудник НЕ член отдела: используем только RoleAssignment
via_assignment = True
logger.warning(
f"[set_member_role] via_assignment branch: emp={emp_id}, role={role_id}"
)
if role:
# Назначаем роль через RoleAssignment
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
if _is_ldap_enabled():
try:
logger.warning(
f"[set_member_role] calling assign_role via LDAP"
)
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
dept_service.assign_role(employee, role, request.user)
logger.warning(f"[set_member_role] assign_role OK")
except Exception as e:
logger.error(
f"[set_member_role] assign_role FAILED: {type(e).__name__}: {e}"
)
import traceback
logger.error(
f"[set_member_role] traceback:\n{traceback.format_exc()}"
)
return Response({"detail": str(e)}, status=400)
else:
RoleAssignment.objects.update_or_create(
employee=employee,
role=role,
defaults={"is_active": True, "assigned_by": request.user},
)
else:
# Снятие всех ролей отдела для этого сотрудника
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
# Находим активные роли сотрудника в этом отделе
active_assignments = RoleAssignment.objects.filter(
employee_id=emp_id, role__department=dept, is_active=True
).select_related("role")
if _is_ldap_enabled():
# Удаляем из LDAP-групп для каждой роли
try:
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
for assignment in active_assignments:
dept_service.revoke_role(employee, assignment.role)
except Exception as e:
logger.error(
f"[set_member_role] revoke_role FAILED: {type(e).__name__}: {e}"
)
return Response({"detail": str(e)}, status=400)
else:
active_assignments.update(is_active=False)
return Response(
{
"employee_id": emp_id,
"role_id": (role.id if role else None),
"is_active": link.is_active if link else True,
"via_assignment": via_assignment,
},
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",
"ldap_info": "employees.view_ldap_info", # Для просмотра чужих LDAP логинов
}
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",
"ldap_info", # Свой LDAP логин может видеть сам пользователь
}:
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
# по отделу: связи + руководитель + сотрудники с ролями через RoleAssignment
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")
# Сотрудники с ролями через RoleAssignment (не члены отдела)
role_assignment_ids = RoleAssignment.objects.filter(
role__department_id=dep_id, is_active=True
).values("employee_id")
qs = qs.filter(
Q(id__in=member_ids)
| Q(id__in=head_ids)
| Q(id__in=role_assignment_ids)
).distinct()
# Аннотируем тип связи с отделом для каждого сотрудника
# is_dept_member: True если член отдела, False если только роль
qs = qs.annotate(
_is_dept_member=Exists(
EmployeeDepartment.objects.filter(
employee_id=OuterRef("pk"),
department_id=dep_id,
is_active=True,
)
),
_is_dept_head=Exists(
Department.objects.filter(id=dep_id, head_id=OuterRef("pk"))
),
_has_role_assignment=Exists(
RoleAssignment.objects.filter(
employee_id=OuterRef("pk"),
role__department_id=dep_id,
is_active=True,
)
),
)
# Сохраняем dep_id в request для использования в сериализаторе
self.request._department_filter_id = dep_id
# по должности
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.avatar.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
import logging
import traceback
logger = logging.getLogger(__name__)
try:
old_email = instance.email # Сохраняем старый email
# Логируем для отладки
logger.info(
f"[ME PATCH] START - user_id={instance.id}, email={instance.email}"
)
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:
avatar_data = request.data["avatar"]
avatar_type = type(avatar_data).__name__
logger.info(f"[ME PATCH] ⚠️ avatar В request.data:")
logger.info(f" - type: {avatar_type}")
logger.info(f" - value: {repr(avatar_data)[:200]}")
logger.info(f" - is empty string: {avatar_data == ''}")
logger.info(f" - is None: {avatar_data is None}")
logger.info(f" - bool(avatar_data): {bool(avatar_data)}")
if hasattr(avatar_data, "__len__"):
logger.info(f" - length: {len(avatar_data) if avatar_data else 0}")
else:
logger.info(f"[ME PATCH] ✓ avatar НЕТ в request.data")
if "avatar" in request.FILES:
avatar_file = request.FILES["avatar"]
logger.info(f"[ME PATCH] ⚠️ avatar В request.FILES:")
logger.info(f" - name: {avatar_file.name}")
logger.info(f" - size: {avatar_file.size}")
logger.info(f" - content_type: {avatar_file.content_type}")
else:
logger.info(f"[ME PATCH] ✓ avatar НЕТ в request.FILES")
# КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: удаляем пустое поле avatar ДО валидации
data_for_serializer = request.data
if "avatar" in request.data:
avatar_value = request.data.get("avatar")
logger.info(
f"[ME PATCH] <20> Проверяем avatar: type={type(avatar_value)}, value={repr(avatar_value)[:100]}"
)
# Если avatar - пустая строка, создаем копию данных без него
if avatar_value == "":
logger.warning(
f"[ME PATCH] 🔧 Удаляем пустое поле avatar из данных"
)
data_for_serializer = {
k: v for k, v in request.data.items() if k != "avatar"
}
logger.info("[ME PATCH] Step 1: Validating serializer...")
logger.info(
f"[ME PATCH] Step 1: Передаем в сериализатор data с ключами: {list(data_for_serializer.keys())}"
)
try:
ser = self.get_serializer(
instance, data=data_for_serializer, partial=True
)
ser.is_valid(raise_exception=True)
logger.info("[ME PATCH] Step 1: ✓ Serializer validated successfully")
except ValidationError as ve:
logger.error(f"[ME PATCH] Step 1: ❌ Serializer validation FAILED:")
logger.error(f" - error detail: {ve.detail}")
logger.error(f" - error detail type: {type(ve.detail)}")
if hasattr(ve.detail, "items"):
for field, errors in ve.detail.items():
logger.error(f" - field '{field}': {errors}")
raise
vd = dict(ser.validated_data)
logger.info(f"[ME PATCH] Step 2: validated_data keys: {list(vd.keys())}")
if "avatar" in vd:
avatar_obj = vd.get("avatar")
logger.info(f"[ME PATCH] Step 2: avatar В validated_data:")
logger.info(f" - type: {type(avatar_obj)}")
logger.info(f" - hasattr read: {hasattr(avatar_obj, 'read')}")
logger.info(f" - repr: {repr(avatar_obj)[:200]}")
if avatar_obj:
logger.info(f" - name: {getattr(avatar_obj, 'name', None)}")
logger.info(f" - size: {getattr(avatar_obj, 'size', None)}")
else:
logger.info(f"[ME PATCH] Step 2: ✓ avatar НЕТ в validated_data")
# Проверяем изменение email
logger.info("[ME PATCH] Step 3: Checking email change...")
new_email = vd.get("email")
email_changed = new_email and new_email.lower() != old_email.lower()
logger.info(f"[ME PATCH] Step 3: email_changed={email_changed}")
# Бизнес-валидация каналов связи
logger.info("[ME PATCH] Step 4: Validating contact channels...")
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):
logger.warning("[ME PATCH] Step 4: Contact validation failed")
return Response(
{
"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."
},
status=400,
)
logger.info("[ME PATCH] Step 4: Contact validation passed")
ldap_enabled = _is_ldap_enabled()
logger.info(f"[ME PATCH] Step 5: LDAP enabled={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}
logger.info(
f"[ME PATCH] Step 6: ldap_changes keys: {list(ldap_changes.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)
logger.info(f"[ME PATCH] Step 6: position={pos_raw}")
move_to_department_dn = request.data.get("department_dn")
group_cns = request.data.get("group_cns")
logger.info(
f"[ME PATCH] Step 7: move_to_department_dn={move_to_department_dn}, group_cns={group_cns}"
)
# Проверяем аватар в validated_data (base64) или в FILES (FormData)
logger.info("[ME PATCH] Step 8: Processing avatar...")
avatar_file = ser.validated_data.get("avatar") or request.FILES.get(
"avatar"
)
logger.info(
f"[ME PATCH] Step 8: avatar_file={avatar_file}, type={type(avatar_file) if avatar_file else None}"
)
if avatar_file and hasattr(avatar_file, "read"):
try:
logger.info("[ME PATCH] Step 8: Reading avatar bytes...")
svc_changes["avatar_bytes"] = avatar_file.read()
logger.info(
f"[ME PATCH] Step 8: avatar_bytes read successfully, length={len(svc_changes['avatar_bytes'])}"
)
except Exception as e:
logger.error(
f"[ME PATCH] Step 8: Error reading avatar: {e}", exc_info=True
)
logger.info(
f"[ME PATCH] Step 9: svc_changes keys: {list(svc_changes.keys())}"
)
# Проверяем, есть ли у пользователя ldap_dn
has_ldap_dn = False
if ldap_enabled:
from employees.models import LdapSyncState
has_ldap_dn = LdapSyncState.objects.filter(
model="employee", object_pk=str(instance.pk), ldap_dn__isnull=False
).exists() or (hasattr(instance, "ldap_dn") and instance.ldap_dn)
logger.info(
f"[ME PATCH] Step 9.5: LDAP enabled, user has ldap_dn: {has_ldap_dn}"
)
if (
ldap_enabled
and has_ldap_dn
and (svc_changes or move_to_department_dn or group_cns is not None)
):
# Режим с LDAP: обновляем через DirectoryService (только для пользователей с ldap_dn)
logger.info(
"[ME PATCH] Step 10: LDAP mode - updating via DirectoryService..."
)
logger.info(
f"[ME PATCH] Step 10: DirectoryService params - svc_changes={list(svc_changes.keys())}, move_to_department_dn={move_to_department_dn}, group_cns={group_cns}"
)
svc = DirectoryService()
try:
logger.info(
f"[ME PATCH] Step 10: Calling svc.update_user(instance.id={instance.id}, changes={svc_changes}, group_cns={group_cns}, move_to_department_dn={move_to_department_dn})"
)
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,
)
logger.info(
f"[ME PATCH] Step 10: DirectoryService update successful - updated instance.id={instance.id}"
)
if "avatar_bytes" in svc_changes:
logger.info(
f"[ME PATCH] Step 10: Avatar updated in LDAP, new path={instance.avatar.name if instance.avatar else 'None'}"
)
except (
DirectoryLdapError,
DirectoryDbError,
DirectoryServiceError,
) as e:
logger.error(
f"[ME PATCH] Step 10: DirectoryService error: type={type(e).__name__}, message={e}",
exc_info=True,
)
return Response(
{"detail": str(e)},
status=502 if isinstance(e, DirectoryLdapError) else 500,
)
elif (not ldap_enabled or not has_ldap_dn) and svc_changes:
# Режим без LDAP или пользователь без ldap_dn: обновляем напрямую в БД
logger.info(
f"[ME PATCH] Step 10: LDAP disabled or no ldap_dn, updating DB directly with: {list(svc_changes.keys())}"
)
for k, v in svc_changes.items():
if k != "position" and k != "avatar_bytes":
setattr(instance, k, v)
logger.info(f"[ME PATCH] Step 10: Set {k} = {v}")
elif k == "position":
instance.position_id = v
logger.info(f"[ME PATCH] Step 10: Set position_id = {v}")
elif k == "avatar_bytes" and v:
logger.info(f"[ME PATCH] Step 10: Saving avatar, size={len(v)}")
filename = f"avatar_{instance.id}.jpg"
instance.avatar.save(
filename,
ContentFile(v),
save=False,
)
logger.info(
f"[ME PATCH] Step 10: Avatar saved as {filename}, path={instance.avatar.name if instance.avatar else 'None'}"
)
logger.info("[ME PATCH] Step 10: Saving instance to DB...")
instance.save()
logger.info(
f"[ME PATCH] Step 10: Instance saved, avatar path={instance.avatar.name if instance.avatar else 'None'}"
)
# DB-only
logger.info(
f"[ME PATCH] Step 11: Processing DB-only fields, vd keys: {list(vd.keys())}"
)
# КРИТИЧНО: удаляем avatar из vd, т.к. он уже обработан выше
if "avatar" in vd:
logger.info(
"[ME PATCH] Step 11: Removing avatar from vd (already processed)"
)
vd.pop("avatar")
if vd:
logger.info(
"[ME PATCH] Step 11: Validating and saving DB-only fields..."
)
ser_db = self.get_serializer(instance, data=vd, partial=True)
try:
ser_db.is_valid(raise_exception=True)
ser_db.save()
instance = ser_db.instance
data = ser_db.data
logger.info("[ME PATCH] Step 11: DB-only fields saved successfully")
except ValidationError as ve:
logger.warning(f"[ME PATCH] Step 11: Validation error: {ve.detail}")
return Response(ve.detail, status=400)
else:
logger.info("[ME PATCH] Step 11: No DB-only fields to save")
data = self.get_serializer(instance).data
logger.info(
f"[ME PATCH] Step 12: Final response data keys: {list(data.keys())}"
)
if "avatar" in data:
avatar_preview = data["avatar"][:100] if data["avatar"] else None
logger.info(
f"[ME PATCH] Step 12: avatar in response: {avatar_preview}..."
)
# Сброс email_verified при изменении email
if email_changed:
logger.info("[ME PATCH] Step 13: Processing email change...")
from common.emails import send_templated_mail
from django.utils.crypto import get_random_string
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,
},
)
logger.info("[ME PATCH] Step 13: Email verification sent")
except Exception as email_err:
logger.warning(
f"[ME PATCH] Step 13: Failed to send email: {email_err}"
)
# Обновляем данные в ответе
data["email_verified"] = False
logger.info("[ME PATCH] SUCCESS - returning response")
return Response(data, status=200)
except Exception as e:
logger.error(f"[ME PATCH] FATAL ERROR: {e}", exc_info=True)
logger.error(f"[ME PATCH] Traceback:\n{traceback.format_exc()}")
return Response({"detail": f"Internal server error: {str(e)}"}, status=500)
@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
)
@action(detail=True, methods=["get"], url_path="ldap-info")
def ldap_info(self, request, pk=None):
"""
GET /api/v1/employees/{id}/ldap-info/?force_refresh=true
Получает LDAP информацию о сотруднике (с кэшированием).
Query параметры:
- force_refresh: если true, принудительно обновляет кэш из LDAP
Доступ:
- Любой авторизованный пользователь может видеть LDAP логин
- Кэшируется в поле Employee.username для быстрого доступа
- При force_refresh=true - сбрасывает кэш и запрашивает из LDAP
Возвращает:
- sAMAccountName: LDAP логин пользователя (из кэша или LDAP)
- cached: true если данные из кэша, false если запрошены из LDAP
Примечания:
- Кэшируется в Employee.username для быстрого доступа
- При отсутствии кэша - запрашивается из LDAP и сохраняется
- force_refresh=true сбрасывает кэш и обновляет из LDAP
"""
from employees.ldap.repositories.ldap_repository import LdapRepository
emp = self.get_object()
force_refresh = request.query_params.get('force_refresh', '').lower() == 'true'
# Проверяем кэш (поле username), если не требуется принудительное обновление
if not force_refresh and emp.username:
return Response(
{
"sAMAccountName": emp.username,
"cached": True,
},
status=status.HTTP_200_OK,
)
# Проверяем наличие LDAP связи через LdapSyncState
try:
ldap_sync = LdapSyncState.objects.get(
model='employee',
object_pk=str(emp.pk)
)
if not ldap_sync.ldap_dn:
return Response(
{"detail": "У этого сотрудника нет связи с LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
except LdapSyncState.DoesNotExist:
return Response(
{"detail": "У этого сотрудника нет связи с LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
# Запрашиваем данные из LDAP
try:
conn = _conn()
ldap_repo = LdapRepository(conn)
attrs = ldap_repo.read_attrs(
ldap_sync.ldap_dn,
["sAMAccountName"],
)
if not attrs or not attrs.get("sAMAccountName"):
return Response(
{"detail": "Не удалось получить LDAP информацию"},
status=status.HTTP_404_NOT_FOUND,
)
# Кэшируем результат в username
sam_account_name = attrs.get("sAMAccountName")
emp.username = sam_account_name
emp.save(update_fields=["username"])
return Response(
{
"sAMAccountName": sam_account_name,
"cached": False,
},
status=status.HTTP_200_OK,
)
except Exception as e:
logger.error(
f"Error fetching LDAP info for employee {emp.id}: {e}", exc_info=True
)
return Response(
{"detail": f"Ошибка при запросе LDAP информации: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@action(detail=False, methods=["get"], url_path="export-excel")
def export_excel(self, request):
"""
GET /api/v1/employees/export-excel/
Экспортирует всех сотрудников в Excel формат.
Параметры:
- Применяются все фильтры из queryset (department, position, active и т.д.)
Возвращает:
- Excel файл (.xlsx) с данными сотрудников
"""
from datetime import datetime
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
# Получаем отфильтрованный queryset
queryset = self.filter_queryset(self.get_queryset())
queryset = (
queryset.select_related("position")
.prefetch_related(
"skills", "departments_links__department", "departments_links__role"
)
.order_by("last_name", "first_name")
)
# Создаём Excel файл
wb = Workbook()
ws = wb.active
ws.title = "Сотрудники"
# Заголовки
headers = [
"ID",
"Фамилия",
"Имя",
"Отчество",
"Email",
"Телефон",
"Должность",
"Отделы",
"Дата рождения",
"Дата регистрации",
"Активен",
"Email подтвержден",
"Навыки",
"Telegram",
"WhatsApp",
"WeChat",
]
# Стиль заголовков
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(
start_color="4472C4", end_color="4472C4", fill_type="solid"
)
header_alignment = Alignment(
horizontal="center", vertical="center", wrap_text=True
)
# Записываем заголовки
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# Записываем данные
for row_num, emp in enumerate(queryset, 2):
# Получаем отделы
departments = emp.departments_links.filter(is_active=True)
dept_names = ", ".join(
[
f"{d.department.name}" + (f" ({d.role.name})" if d.role else "")
for d in departments
]
)
# Получаем навыки
skills = ", ".join([s.name for s in emp.skills.all()])
# Конвертируем PhoneNumber поля в строки
def safe_phone_str(phone_field):
"""Безопасная конвертация PhoneNumber в строку"""
if not phone_field:
return ""
try:
from phonenumbers import PhoneNumberFormat, format_number
return format_number(phone_field, PhoneNumberFormat.INTERNATIONAL)
except Exception:
try:
return str(phone_field)
except Exception:
return ""
phone_str = safe_phone_str(emp.phone_number)
whatsapp_str = safe_phone_str(emp.whatsapp)
# Данные строки
row_data = [
emp.id,
emp.last_name or "",
emp.first_name or "",
emp.patronymic or "",
emp.email or "",
phone_str,
emp.position.name if emp.position else "",
dept_names,
emp.birth_date.strftime("%d.%m.%Y") if emp.birth_date else "",
emp.created_at.strftime("%d.%m.%Y %H:%M") if emp.created_at else "",
"Да" if emp.is_active else "Нет",
"Да" if emp.email_verified else "Нет",
skills,
emp.telegram or "",
whatsapp_str,
emp.wechat or "",
]
for col_num, value in enumerate(row_data, 1):
cell = ws.cell(row=row_num, column=col_num, value=value)
cell.alignment = Alignment(vertical="top", wrap_text=True)
# Автоподбор ширины колонок
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50) # Максимум 50
ws.column_dimensions[column].width = adjusted_width
# Закрепляем первую строку (заголовки)
ws.freeze_panes = "A2"
# Создаём HTTP ответ
response = HttpResponse(
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
filename = f"employees_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
# Сохраняем в response
wb.save(response)
return response
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 для проверки
print("=" * 80)
print(
"[EMP PATCH] pk=%s, actor=%s, method=%s"
% (emp.pk, getattr(request.user, "id", None), request.method)
)
print(f"[EMP PATCH] request.data keys: {list(request.data.keys())}")
print(f"[EMP PATCH] request.FILES keys: {list(request.FILES.keys())}")
print(f"[EMP PATCH] Content-Type: {request.content_type}")
if "avatar" in request.data:
avatar_data = request.data["avatar"]
avatar_len = len(str(avatar_data)) if avatar_data else 0
print(
"[EMP PATCH] avatar in request.data: type=%s, length=%s"
% (type(avatar_data), avatar_len)
)
if "avatar" in request.FILES:
avatar_file_req = request.FILES["avatar"]
print(
"[EMP PATCH] avatar in request.FILES: name=%s, size=%s"
% (avatar_file_req.name, avatar_file_req.size)
)
data = request.data.copy()
if hasattr(data, "_mutable") and not data._mutable:
data._mutable = True
avatar_raw = data.get("avatar")
if avatar_raw in ("", None):
data.pop("avatar", None)
print("[EMP PATCH] removed empty avatar value from payload")
ser = self.get_serializer(emp, data=data, partial=True)
try:
ser.is_valid(raise_exception=True)
except ValidationError as exc:
print("[EMP PATCH] serializer errors: %s" % exc.detail)
raise
vd = dict(ser.validated_data)
ldap_enabled = _is_ldap_enabled()
print(f"[EMP PATCH] validated_data keys: {list(vd.keys())}")
if "avatar" in vd:
avatar_vd = vd["avatar"]
print(
("[EMP PATCH] avatar in validated_data: type=%s, hasattr read=%s")
% (type(avatar_vd), hasattr(avatar_vd, "read"))
)
if hasattr(avatar_vd, "name"):
print(
"[EMP PATCH] avatar object info: name=%s, size=%s"
% (
getattr(avatar_vd, "name", None),
getattr(avatar_vd, "size", None),
)
)
# Проверяем изменение 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 data) or ("position_id" in data)
if pos_key_present:
pos_raw = (
data.get("position") if "position" in data else data.get("position_id")
)
svc_changes["position"] = pos_raw # None допустим
vd.pop("position", None)
vd.pop("position_id", None)
move_to_department_dn = data.get("department_dn")
group_cns = data.get("group_cns") # список строк/None
# avatar → bytes (если разрешено править аватар в LDAP)
avatar_file = ser.validated_data.get("avatar")
avatar_file_type = type(avatar_file) if avatar_file else None
print(
"[EMP PATCH] avatar_file extracted: %s, type=%s"
% (avatar_file, avatar_file_type)
)
if avatar_file and hasattr(avatar_file, "read"):
try:
svc_changes["avatar_bytes"] = avatar_file.read()
if hasattr(avatar_file, "seek"):
avatar_file.seek(0)
print(
"[EMP PATCH] avatar_bytes prepared for LDAP, length=%s"
% len(svc_changes["avatar_bytes"])
)
except Exception as exc:
print(f"[EMP PATCH] Error reading avatar for LDAP: {exc}")
# Проверяем, есть ли у пользователя ldap_dn
has_ldap_dn = False
if ldap_enabled:
from employees.models import LdapSyncState
has_ldap_dn = LdapSyncState.objects.filter(
model="employee", object_pk=str(emp.pk), ldap_dn__isnull=False
).exists() or (hasattr(emp, "ldap_dn") and emp.ldap_dn)
print(f"[EMP PATCH] LDAP enabled, user has ldap_dn: {has_ldap_dn}")
if ldap_enabled and has_ldap_dn:
# LDAP mode: sync with DirectoryService (только для пользователей с ldap_dn)
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 or user without ldap_dn: 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()
print(
"[EMP PATCH] Non-LDAP avatar bytes len=%s"
% (len(avatar_bytes) if avatar_bytes else 0)
)
emp.avatar.save(
avatar_file.name,
ContentFile(avatar_bytes),
save=False,
)
vd.pop("avatar", None)
print(
("[EMP PATCH] Avatar saved to model field, current path=%s")
% (emp.avatar.name if emp.avatar else None)
)
except Exception as exc:
print("[EMP PATCH] Error while saving avatar in DB mode: %s" % exc)
# --- DB-only часть ---
# Обновляем только оставшиеся поля, чтобы не перетирать работу сервиса
# КРИТИЧНО: удаляем avatar из vd, т.к. он уже обработан выше
if "avatar" in vd:
print("[EMP PATCH] Removing avatar from vd (already processed)")
vd.pop("avatar")
if vd:
ser_db = self.get_serializer(emp, data=vd, partial=True)
try:
ser_db.is_valid(raise_exception=True)
ser_db.save()
emp = ser_db.instance
data = ser_db.data
except ValidationError as exc:
print("[EMP PATCH] DB serializer validation error: %s" % exc.detail)
return Response(exc.detail, status=400)
else:
data = self.get_serializer(emp).data
print(f"[EMP PATCH] Final response keys: {list(data.keys())}")
if isinstance(data, dict) and data.get("avatar"):
print("[EMP PATCH] avatar preview in response: %s..." % data["avatar"][:80])
# Сброс email_verified при изменении email
if email_changed:
from common.emails import send_templated_mail
from django.utils.crypto import get_random_string
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",
"assign",
"revoke",
}:
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
def create(self, request, *args, **kwargs):
"""Создание роли: сначала группа в LDAP → затем запись в БД (если LDAP включен)."""
ser = self.get_serializer(data=request.data)
ser.is_valid(raise_exception=True)
dept_id = ser.validated_data.get("department")
if isinstance(dept_id, Department):
dept = dept_id
else:
dept = get_object_or_404(Department, id=dept_id)
name = ser.validated_data["name"]
codes = ser.validated_data.pop("scoped_permission_codes", None)
perms = ser.validated_data.pop("scoped_permissions", None)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: создаём через DepartmentService
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
# Подготавливаем scoped_permissions (codes имеют приоритет над ids)
scoped_permissions = None
if codes is not None:
scoped_permissions = list(
DepartmentPermission.objects.filter(code__in=codes)
)
elif perms is not None:
scoped_permissions = list(perms)
try:
role = dept_service.create_role(
department=dept,
name=name,
scoped_permissions=scoped_permissions,
)
return Response(
self.get_serializer(role).data, status=status.HTTP_201_CREATED
)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
# Режим без LDAP: создаём напрямую через сериализатор
role = DepartmentRole.objects.create(
department=dept,
name=name,
)
# codes имеют приоритет над ids (как в сериализаторе)
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
role.scoped_permissions.set(list(qs))
elif perms is not None:
role.scoped_permissions.set(perms)
return Response(
self.get_serializer(role).data, status=status.HTTP_201_CREATED
)
def update(self, request, *args, **kwargs):
"""Обновление роли: сначала LDAP → затем БД (если LDAP включен)."""
partial = kwargs.pop("partial", False)
instance = self.get_object()
ser = self.get_serializer(instance, data=request.data, partial=partial)
ser.is_valid(raise_exception=True)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: обновляем через DepartmentService
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
changes = {}
if "name" in ser.validated_data:
changes["name"] = ser.validated_data["name"]
if "scoped_permissions" in ser.validated_data:
changes["scoped_permissions"] = ser.validated_data["scoped_permissions"]
if "scoped_permission_codes" in ser.validated_data:
changes["scoped_permission_codes"] = ser.validated_data[
"scoped_permission_codes"
]
try:
role = dept_service.update_role(instance, changes)
return Response(self.get_serializer(role).data)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
# Режим без LDAP: обновляем через сериализатор
self.perform_update(ser)
return Response(ser.data)
def partial_update(self, request, *args, **kwargs):
"""Частичное обновление роли."""
kwargs["partial"] = True
return self.update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""Удаление роли: сначала группа из LDAP → затем запись из БД (если LDAP включен)."""
instance = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: удаляем через DepartmentService
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
try:
dept_service.delete_role(instance)
return Response(status=status.HTTP_204_NO_CONTENT)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
# Режим без LDAP: удаляем напрямую
instance.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@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)
@action(detail=True, methods=["get"])
def assignments(self, request, pk=None):
"""
Возвращает список назначений роли (RoleAssignment).
Query params:
- ?active=true/false — фильтр по is_active (по умолчанию только активные)
"""
role = self.get_object()
qs = RoleAssignment.objects.filter(role=role).select_related(
"employee", "assigned_by"
)
active = request.query_params.get("active", "true").lower()
if active == "true":
qs = qs.filter(is_active=True)
elif active == "false":
qs = qs.filter(is_active=False)
data = [
{
"id": a.id,
"employee_id": a.employee_id,
"employee_name": str(a.employee) if a.employee else None,
"assigned_at": a.assigned_at.isoformat() if a.assigned_at else None,
"assigned_by_id": a.assigned_by_id,
"assigned_by_name": str(a.assigned_by) if a.assigned_by else None,
"is_active": a.is_active,
}
for a in qs.order_by("-assigned_at")
]
return Response({"count": len(data), "results": data}, status=200)
@action(detail=True, methods=["post"])
def assign(self, request, pk=None):
"""
Назначает роль сотруднику (не требует членства в отделе).
Тело запроса:
- employee_id: int — ID сотрудника
Требуется право DeptPerm.ASSIGN_ROLE в отделе роли.
При включенном LDAP: сначала добавляет в группу LDAP, потом в БД.
"""
role = self.get_object()
employee_id = request.data.get("employee_id")
if not employee_id:
return Response({"detail": "employee_id is required."}, status=400)
try:
employee = Employee.objects.get(id=employee_id)
except Employee.DoesNotExist:
return Response({"detail": "Сотрудник не найден."}, status=404)
assigned_by = request.user if request.user.is_authenticated else None
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: через DepartmentService (сначала LDAP, потом БД)
try:
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
assignment = dept_service.assign_role(employee, role, assigned_by)
return Response(
{
"id": assignment.id,
"employee_id": assignment.employee_id,
"role_id": assignment.role_id,
"assigned_at": assignment.assigned_at.isoformat()
if assignment.assigned_at
else None,
"is_active": assignment.is_active,
},
status=201,
)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
# Режим без LDAP: создаём RoleAssignment напрямую
assignment, created = RoleAssignment.objects.update_or_create(
employee=employee,
role=role,
defaults={
"is_active": True,
"assigned_by": assigned_by,
},
)
return Response(
{
"id": assignment.id,
"employee_id": assignment.employee_id,
"role_id": assignment.role_id,
"assigned_at": assignment.assigned_at.isoformat()
if assignment.assigned_at
else None,
"is_active": assignment.is_active,
},
status=201,
)
@action(detail=True, methods=["post"])
def revoke(self, request, pk=None):
"""
Отзывает роль у сотрудника.
Тело запроса:
- employee_id: int — ID сотрудника
Требуется право DeptPerm.ASSIGN_ROLE в отделе роли.
При включенном LDAP: сначала удаляет из группы LDAP, потом из БД.
"""
role = self.get_object()
employee_id = request.data.get("employee_id")
if not employee_id:
return Response({"detail": "employee_id is required."}, status=400)
try:
employee = Employee.objects.get(id=employee_id)
except Employee.DoesNotExist:
return Response({"detail": "Сотрудник не найден."}, status=404)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: через DepartmentService (сначала LDAP, потом БД)
try:
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
dept_service.revoke_role(employee, role)
return Response(status=204)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
else:
# Режим без LDAP: деактивируем RoleAssignment напрямую
RoleAssignment.objects.filter(employee=employee, role=role).update(
is_active=False
)
return Response(status=204)
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
ldap_enabled = _is_ldap_enabled()
if action_obj.action == ACTION_DISMISSED:
# ВАЖНО: сначала деактивируем сотрудника (чтобы get_base_dn_for_employee работал правильно)
if emp.is_active:
emp.is_active = False
emp.save(update_fields=["is_active"])
# деактивируем связи с отделами в БД
EmployeeDepartment.objects.filter(employee=emp, is_active=True).update(
is_active=False, date_to=timezone.now().date()
)
# синхронизируем с LDAP (disable учётной записи)
if ldap_enabled:
try:
svc = DirectoryService()
# Проверяем, есть ли у пользователя ldap_dn
from employees.models import LdapSyncState
has_ldap_dn = LdapSyncState.objects.filter(
model="employee", object_pk=str(emp.pk), ldap_dn__isnull=False
).exists()
if has_ldap_dn:
# Обновляем is_active в LDAP (установит userAccountControl=514)
svc.update_user(emp, changes={"is_active": False})
# Удаляем из всех отделов (это переместит в OU=Dismissed)
active_departments = EmployeeDepartment.objects.filter(
employee=emp
).select_related("department")
departments_processed = False
for emp_dept in active_departments:
try:
svc.remove_member(emp_dept.department, emp)
departments_processed = True
except (
DirectoryLdapError,
DirectoryDbError,
DirectoryServiceError,
) as e:
# Логируем ошибку удаления из конкретного отдела
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Failed to remove dismissed employee from department: "
f"employee_id={emp.id}, department_id={emp_dept.department.id}, error={e}"
)
# Если сотрудник не состоял ни в одном отделе, вручную перемещаем в OU=Dismissed
if not departments_processed:
try:
from employees.ldap.infrastructure.connections import (
_ldap,
)
from employees.ldap.repositories.ldap_repository import (
ensure_container_exists,
)
from employees.ldap.utils.ldap_utils import (
get_base_dn_for_employee,
)
from employees.models import LdapSyncState
sync_state = LdapSyncState.objects.filter(
model="employee", object_pk=str(emp.pk)
).first()
if sync_state and sync_state.ldap_dn:
target_base = get_base_dn_for_employee(emp)
current_dn = sync_state.ldap_dn
# Проверяем, нужно ли перемещение
if not current_dn.lower().endswith(
target_base.lower()
):
with _ldap() as conn:
ensure_container_exists(conn, target_base)
new_dn = (
svc._user_service._move_user_to_base(
conn, current_dn, target_base
)
)
sync_state.touch(
ldap_dn=new_dn, sync_dir="ldap"
)
import logging
logger = logging.getLogger(__name__)
logger.info(
f"Dismissed employee without department moved to OU=Dismissed: "
f"employee_id={emp.id}, new_dn={new_dn}"
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Failed to move dismissed employee without department to OU=Dismissed: "
f"employee_id={emp.id}, error={e}"
)
except (
DirectoryLdapError,
DirectoryDbError,
DirectoryServiceError,
) as e:
# Логируем ошибку, но не прерываем процесс
# БД-изменения уже применены
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Failed to disable user in LDAP during dismissal: "
f"employee_id={emp.id}, error={e}"
)
else:
# любое иное событие делает сотрудника активным в БД
was_inactive = not emp.is_active
if was_inactive:
emp.is_active = True
emp.save(update_fields=["is_active"])
# синхронизируем с LDAP (enable учётной записи)
if ldap_enabled:
try:
svc = DirectoryService()
from employees.models import LdapSyncState
has_ldap_dn = LdapSyncState.objects.filter(
model="employee", object_pk=str(emp.pk), ldap_dn__isnull=False
).exists()
if has_ldap_dn:
# Обновляем is_active в LDAP (установит userAccountControl=512)
svc.update_user(emp, changes={"is_active": True})
# Если сотрудник был неактивен (восстановление из увольнения),
# перемещаем из OU=Dismissed в OU=Users
if was_inactive:
try:
sync_state = LdapSyncState.objects.get(
model="employee", object_pk=str(emp.pk)
)
current_dn = sync_state.ldap_dn
dismissed_base = getattr(
settings, "LDAP_DISMISSED_BASE", ""
)
# Проверяем, находится ли сотрудник в OU=Dismissed
if dismissed_base and current_dn.lower().endswith(
dismissed_base.lower()
):
users_base = getattr(
settings, "LDAP_USERS_BASE", None
) or getattr(settings, "LDAP_USER_BASE", None)
if users_base:
from employees.ldap.infrastructure.connections import (
_ldap,
)
from employees.ldap.repositories.ldap_repository import (
ensure_container_exists,
)
with _ldap() as conn:
ensure_container_exists(conn, users_base)
# Используем внутренний метод UserService
new_dn = (
svc._user_service._move_user_to_base(
conn, current_dn, users_base
)
)
sync_state.touch(
ldap_dn=new_dn, sync_dir="ldap"
)
import logging
logger = logging.getLogger(__name__)
logger.info(
f"Restored employee moved from Dismissed to Users: "
f"employee_id={emp.id}, new_dn={new_dn}"
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Failed to move restored employee from Dismissed to Users: "
f"employee_id={emp.id}, error={e}"
)
except (
DirectoryLdapError,
DirectoryDbError,
DirectoryServiceError,
) as e:
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Failed to enable user in LDAP during action: "
f"employee_id={emp.id}, action={action_obj.action}, error={e}"
)
@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):
"""Маппит 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,
)