2776 lines
108 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# backend/api/v1/employees/views.py
from __future__ import annotations
import traceback
from datetime import timedelta
from typing import Any, Dict, List, Optional
from common.emails import send_templated_mail
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.exceptions import FieldError
from django.core.files.base import ContentFile
from django.db import transaction
from django.db.models import (
Case,
Count,
Exists,
F,
IntegerField,
OuterRef,
Prefetch,
Q,
Subquery,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.crypto import get_random_string
from employees.constants import ACTION_DISMISSED
from employees.ldap.connections import _conn
from employees.ldap.directory_service import (
DirectoryDbError,
DirectoryDepartmentDTO,
DirectoryLdapError,
DirectoryService,
DirectoryServiceError,
DirectoryUserDTO,
)
from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
DeptPerm,
EmployeeAction,
EmployeeDepartment,
LdapSyncState,
Position,
Skill,
)
from employees.utils import (
_build_links_for_dept,
_detect_phone_field,
_ensure_department_permissions,
_head_choices_for_dept,
_normalize_phone,
_perm_choices_synced,
_to_bool,
_validate_head_active,
)
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from ..permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
IsSelfOrStaff,
has_dept_perm,
)
from .serializers import (
AddMemberInput,
DepartmentBriefSerializer,
DepartmentRoleSerializer,
DepartmentSerializer,
EmailSerializer,
EmailVerifySerializer,
EmployeeActionSerializer,
EmployeeBriefSerializer,
EmployeeListSerializer,
EmployeeSerializer,
GroupSerializer,
PositionSerializer,
RegisterSerializer,
RemoveMemberInput,
SetHeadInput,
SetMemberRoleInput,
SkillSerializer,
)
Employee = get_user_model()
PHONE_FIELD = _detect_phone_field()
def _ldap_try(fn):
try:
fn()
return None
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
return Response(
{"detail": f"LDAP sync failed: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
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.
Логика:
1) Ищем пользователя по email, проверяем код и неистёкший срок регистрации.
2) Если verify_email(code) успешен:
- если у пользователя уже есть ldap_dn/ldap_guid → включаем его в LDAP (is_active=True);
- если LDAP-ид отсутствует → возвращаем 409 (регистрация не завершилась созданием LDAP).
3) Возвращаем {ok: True, user_id} либо ошибку.
"""
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)
svc = DirectoryService()
try:
# LDAP запись должна существовать после /auth/register/
if getattr(user, "ldap_guid", None) or getattr(user, "ldap_dn", None):
# Включаем аккаунт в LDAP
user = svc.update_user(user, {"is_active": True})
# На всякий случай активируем запись в БД, если проектом это требуется
if not user.is_active:
user.is_active = True
user.save(update_fields=["is_active"])
else:
# Регистрация не создала LDAP запись — это рассинхронизация потока.
# Создавать здесь не будем (пароль отсутствует и это небезопасно).
return Response(
{
"ok": False,
"error": "ldap_record_missing",
"detail": "LDAP-учётка отсутствует. Повторите регистрацию.",
},
status=409,
)
return Response({"ok": True, "user_id": user.id}, status=200)
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
)
except DirectoryServiceError as e:
return Response(
{"ok": False, "error": "service_error", "detail": str(e)}, status=400
)
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):
# 0) хотя бы один контакт
if not (
request.data.get("telegram")
or request.data.get("whatsapp")
or request.data.get("wechat")
):
return Response(
{
"detail": "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
},
status=400,
)
ser = RegisterSerializer(data=request.data)
ser.is_valid(raise_exception=True)
v = ser.validated_data
email = v["email"].strip().lower()
password = v["password"] # ← берём пароль из запроса
phone_norm = _normalize_phone(
v.get("phone_number") or request.data.get("phone")
)
if not phone_norm:
return Response({"ok": False, "error": "invalid_phone"}, status=400)
if Employee.objects.filter(phone_number=phone_norm).exists():
return Response({"ok": False, "error": "phone_taken"}, status=400)
user = Employee.objects.filter(email__iexact=email).first()
if user and user.email_verified:
return Response({"ok": False, "error": "email_taken"}, status=400)
if user and not user.email_verified:
# есть «подвешенный» пользователь — можно перерегистрировать через LDAP заново или вернуть pending
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"
print(
f"[RegisterAPIView] avatar provided: name={avatar_name}, size={len(avatar_bytes) if avatar_bytes else 0}"
)
except Exception as e:
print(f"[RegisterAPIView] failed to read avatar: {e}")
avatar_bytes = None
avatar_name = None
else:
print("[RegisterAPIView] no avatar in request")
# 1) LDAP: создаём disabled + устанавливаем пароль
svc = DirectoryService()
dto = DirectoryUserDTO(
first_name=v["first_name"],
last_name=v["last_name"],
# patronymic=v["patronymic"],
email=email,
phone_e164=phone_norm,
department_dn=None, # можно из формы
group_cns=[],
initial_password=password, # ← пароль пойдёт ТОЛЬКО в LDAP
avatar_bytes=avatar_bytes, # при желании прочитать из v["avatar"]
is_active=False, # disabled до верификации email
)
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
)
# 2) заполняем доп.поля БД (не относящиеся к LDAP)
if avatar_bytes:
try:
# используем те же байты, чтобы не перематывать stream
emp.avatar.save(
avatar_name, ContentFile(avatar_bytes), save=False
) # <<<
print("[RegisterAPIView] avatar saved to DB field")
except Exception as e:
print(f"[RegisterAPIView] failed to save avatar to DB: {e}")
emp.telegram = v.get("telegram", "")
emp.whatsapp = v.get("whatsapp", "")
emp.wechat = v.get("wechat", "")
emp.birth_date = v["birth_date"]
if v.get("patronymic"):
emp.patronymic = v["patronymic"]
if v.get("gender") is not None:
emp.gender = v["gender"]
pos_id = v.get("position")
if pos_id and Position.objects.filter(pk=pos_id).exists():
emp.position_id = pos_id
emp.save()
skills_ids = v.get("skills") or []
if skills_ids:
emp.skills.set(Skill.objects.filter(pk__in=skills_ids))
# 3) отправляем код верификации
emp.email_activation_code = get_random_string(6, "0123456789")
emp.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[emp.email],
template_base="emails/registration_verify_code",
context={"code": emp.email_activation_code, "user": emp},
)
return Response(
{
"id": emp.id,
"email": emp.email,
"email_verified": emp.email_verified,
"is_active": emp.is_active,
},
status=status.HTTP_201_CREATED,
)
class DepartmentViewSet(viewsets.ModelViewSet):
"""
CRUD отделов + действия:
- POST /departments/{id}/set_head
- POST /departments/{id}/set_member_role
- POST /departments/{id}/add_member
- POST /departments/{id}/remove_member
Права:
- update/partial_update/destroy → manage_department
- set_head → change_department_head
- set_member_role → assign_department_role (назначение/снятие роли)
- add_member/remove_member → manage_department (управление участниками)
- create → staff/superuser
- чтение → аутентифицированным
"""
queryset = Department.objects.select_related("head").prefetch_related("roles").all()
serializer_class = DepartmentSerializer
# пермишены (скоуп-право по отделу)
class ManagePerm(AdminOrDeptAllowed):
"""Право на общее управление отделом."""
required_code = DeptPerm.MANAGE
class ChangeHeadPerm(AdminOrDeptAllowed):
"""Право на смену руководителя отдела."""
required_code = DeptPerm.CHANGE_HEAD
class AssignRolePerm(AdminOrDeptAllowed):
"""Право на назначение ролей участникам отдела."""
required_code = DeptPerm.ASSIGN_ROLE
def get_permissions(self):
if self.action in {"update", "partial_update", "destroy"}:
return [self.ManagePerm()]
if self.action == "set_head":
return [self.ChangeHeadPerm()]
if self.action in {"set_member_role"}:
return [self.AssignRolePerm()]
if self.action in {"add_member", "remove_member"}:
return [self.ManagePerm()]
if self.action == "create":
return [AdminOrActionOrModelPerms()]
if self.action in {
"members",
"user_perms",
"ui_context",
"list",
"retrieve",
"my_departments",
}:
return [IsAuthenticated()]
return [AdminOrActionOrModelPerms()]
# --- поиск и сортировка, как в старой реализации/тестах ---
ordering = ["name"]
ordering_fields = ["name", "id"]
def get_queryset(self):
qs = super().get_queryset()
# ---------- аннотации для employees_count ----------
# Активные линкы данного отдела
active_links = EmployeeDepartment.objects.filter(
department_id=OuterRef("pk"), is_active=True
)
# Подсчёт distinct сотрудников по активным линкам
active_count_subq = (
active_links.values("department_id")
.annotate(c=Count("employee_id", distinct=True))
.values("c")[:1]
)
qs = qs.annotate(
active_count=Coalesce(
Subquery(active_count_subq, output_field=IntegerField()),
Value(0),
),
head_in_active=Exists(active_links.filter(employee_id=OuterRef("head_id"))),
).annotate(
employees_count=F("active_count")
+ Case(
When(
Q(head_id__isnull=False) & Q(head_in_active=False),
then=Value(1),
),
default=Value(0),
output_field=IntegerField(),
)
)
# ---------- /аннотации ----------
# Поиск и сортировка — как и раньше
search = (
self.request.query_params.get("search")
or self.request.query_params.get("q")
or ""
).strip()
if search:
qs = qs.filter(
Q(name__icontains=search)
| Q(description__icontains=search)
| Q(head__first_name__icontains=search)
| Q(head__last_name__icontains=search)
| Q(head__patronymic__icontains=search)
).distinct()
ordering = self.request.query_params.get("ordering")
if ordering in {"name", "-name", "id", "-id"}:
qs = qs.order_by(ordering)
else:
qs = qs.order_by("name")
return qs
# --- частичное изменение: изменение head требует отдельного кода ---
def partial_update(self, request, *args, **kwargs) -> Response:
"""Частичный апдейт отдела через сервисный слой (LDAP → DB).
Логика:
• Если меняется head — сперва права + валидация, затем DirectoryService.set_head().
• Поля name/description — через DirectoryService.update_department().
• Не вызываем super().partial_update() во избежание двойной записи.
Args:
request: DRF Request.
*args: Прочие позиционные аргументы.
**kwargs: Прочие именованные аргументы.
Returns:
Response: Сериализованные данные отдела после применения изменений.
Raises:
(внутренне обрабатываются и мапятся в HTTP-коды)
"""
instance = self.get_object()
data: Dict[str, Any] = request.data
svc = DirectoryService()
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),
},
)
# --- 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 ---
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)
# --- 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:
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
)
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)
# --- применение ---
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)
resp = self.get_serializer(dept).data
print(
"[DEBUG:set_head] response payload prepared:", {"keys": list(resp.keys())}
)
return Response(resp, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
@transaction.atomic
def set_member_role(self, request, pk=None):
"""
Назначает/снимает РОЛЬ существующему участнику отдела.
Не создаёт членство и не меняет is_active.
Тело:
{ "employee_id": <int>, "role_id": <int|null> }
Права:
AssignRolePerm (DeptPerm.ASSIGN_ROLE)
Ответ:
200 {"employee_id":..., "role_id": <int|null>, "is_active": <bool>}
404 если сотрудник не состоит в отделе
400 если роль не принадлежит отделу
"""
dept = self.get_object()
payload = SetMemberRoleInput(data=request.data)
if not payload.is_valid():
return Response(payload.errors, status=400)
emp_id = payload.validated_data["employee_id"]
role_id = payload.validated_data.get("role_id")
# проверяем роль, если указана, что она принадлежит этому отделу
employee_model = Department._meta.get_field("head").remote_field.model
employee = get_object_or_404(employee_model, id=emp_id)
role = None
if role_id is not None:
role = get_object_or_404(DepartmentRole, id=role_id)
if role.department_id != dept.id:
return Response(
{"role_id": ["Role does not belong to this department."]},
status=400,
)
svc = DirectoryService()
try:
# внутри: проверяет наличие линка, меняет роль, (опц.) LDAP-группу
svc.set_member_role(dept, employee, role)
link = EmployeeDepartment.objects.get(
employee_id=emp_id, department_id=dept.id
)
except EmployeeDepartment.DoesNotExist:
return Response(
{"detail": "Employee is not a member of this department."}, status=404
)
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
return Response(
{"detail": str(e)},
status=(
502
if isinstance(e, DirectoryLdapError)
else 400 if isinstance(e, DirectoryServiceError) else 500
),
)
return Response(
{
"employee_id": emp_id,
"role_id": (role.id if role else None),
"is_active": link.is_active,
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["get"], url_path="members")
def members(self, request, pk=None):
"""
GET /api/v1/departments/{id}/members/
Список участников отдела в формате, удобном для фронта-шаблона.
"""
dept = self.get_object()
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
return Response({"count": len(links), "results": links}, status=200)
@action(detail=True, methods=["get"], url_path="user-perms")
def user_perms(self, request, pk=None):
"""
GET /api/v1/departments/{id}/user-perms/
Флаги прав текущего пользователя в рамках отдела.
"""
dept = self.get_object()
uid = getattr(request.user, "id", None)
data = {
"is_head": bool(uid and uid == dept.head_id),
"can_manage": has_dept_perm(request.user, dept.id, DeptPerm.MANAGE),
"can_change_head": has_dept_perm(
request.user, dept.id, DeptPerm.CHANGE_HEAD
),
"can_assign_roles": has_dept_perm(
request.user, dept.id, DeptPerm.ASSIGN_ROLE
),
}
return Response(data, status=200)
@action(detail=True, methods=["get"], url_path="ui-context")
def ui_context(self, request, pk=None):
"""
GET /api/v1/departments/{id}/ui-context/
BFF-агрегатор для страницы отдела: возвращает все необходимые данные одной пачкой.
Поля:
- dept: DepartmentSerializer
- roles: DepartmentRoleSerializer[]
- links: см. /members
- head_choices: [{id,name}]
- dept_perm_choices: [{id,code,name}]
- user_perms: {is_head, can_manage, can_change_head, can_assign_roles}
"""
dept = self.get_object()
dept_data = self.get_serializer(dept).data
roles_qs = (
DepartmentRole.objects.filter(department_id=dept.id)
.prefetch_related("scoped_permissions")
.order_by("name", "id")
)
roles_data = DepartmentRoleSerializer(roles_qs, many=True).data
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
perm_choices = _perm_choices_synced()
head_choices = _head_choices_for_dept(dept, EmployeeBriefSerializer)
user_perms = {
"is_head": (
request.user.id == dept.head_id
if getattr(request.user, "id", None)
else False
),
"can_manage": has_dept_perm(request.user, dept.id, DeptPerm.MANAGE),
"can_change_head": has_dept_perm(
request.user, dept.id, DeptPerm.CHANGE_HEAD
),
"can_assign_roles": has_dept_perm(
request.user, dept.id, DeptPerm.ASSIGN_ROLE
),
}
payload = {
"dept": dept_data,
"roles": roles_data,
"links": links,
"head_choices": head_choices,
"dept_perm_choices": perm_choices,
"user_perms": user_perms,
}
return Response(payload, status=200)
@action(detail=True, methods=["post"], url_path="add_member")
@transaction.atomic
def add_member(self, request, pk: int | None = None):
"""Добавляет сотрудника в отдел: MOVE в OU → активирует линк (LDAP → DB)."""
dept = self.get_object()
payload = AddMemberInput(data=request.data)
payload.is_valid(raise_exception=True)
emp_id = payload.validated_data["employee_id"]
employee_model = Department._meta.get_field("head").remote_field.model
employee = get_object_or_404(employee_model, id=emp_id)
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
)
),
)
@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,
)
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
),
)
@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."""
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)
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)
def destroy(self, request, *args, **kwargs):
"""Удаляет сотрудника: сначала в LDAP → затем из БД."""
print("[DEBUG] Начало удаления сотрудника.")
emp = self.get_object()
print("[DEBUG] Удаляемый сотрудник:", emp)
svc = DirectoryService()
try:
print("[DEBUG] Удаление пользователя в DirectoryService.")
svc.delete_user(emp)
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)
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)
Сортировка:
?ordering=last_name|first_name|created_at|id (+ '-' для DESC)
Примечания:
- В detail/retrieve и в me мы добавляем в контекст сериализатора флаги
include_actions и include_action_history — в ответе придут кадровые
события сотрудника и их история.
- Создание /employees/ доступно ТОЛЬКО staff/superuser, даже если у пользователя
есть модельный пермишен add_employee (это отмечено явно в create()).
"""
serializer_class = EmployeeSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["last_name", "first_name", "patronymic", "email", "phone_number"]
ordering_fields = ["last_name", "first_name", "created_at", "id"]
ordering = ["last_name", "first_name"]
required_perms_by_action = {
"add_skill": "employees.manage_employee_skills",
"remove_skill": "employees.manage_employee_skills",
}
def get_permissions(self):
# create: только staff/superuser (перепроверяете и в методе create)
if self.action == "create":
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
# update/partial_update/destroy: владелец ИЛИ админ/модельные пермишены
if self.action in {
"update",
"partial_update",
"destroy",
"add_skill",
"remove_skill",
}:
return [IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)()]
# list / retrieve / me -> только аутентификация
return [IsAuthenticated()]
def get_queryset(self):
last_action_code_sq = Subquery(
EmployeeAction.objects.filter(employee_id=OuterRef("pk"))
.order_by("-date")
.values("action")[:1]
)
dep_links_prefetch = Prefetch(
"departments_links",
queryset=EmployeeDepartment.objects.filter(is_active=True).select_related(
"department", "role"
),
to_attr="dept_links",
)
prefetches = [
"skills",
dep_links_prefetch,
Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")),
]
qs = (
Employee.objects.select_related("position")
.prefetch_related(*prefetches)
.annotate(last_action_code=last_action_code_sq)
.order_by(*self.ordering)
)
qp = self.request.query_params
# по отделу: связи + руководитель
dep = qp.get("department")
if dep:
try:
dep_id = int(dep)
except (TypeError, ValueError):
dep_id = None
if dep_id:
member_ids = EmployeeDepartment.objects.filter(
department_id=dep_id
).values("employee_id")
head_ids = Department.objects.filter(id=dep_id).values("head_id")
qs = qs.filter(Q(id__in=member_ids) | Q(id__in=head_ids))
# по должности
position = qp.get("position")
if position:
qs = qs.filter(position_id=position)
# по навыкам (any-of)
skill_ids = qp.getlist("skill")
if skill_ids:
qs = qs.filter(skills__in=skill_ids).distinct()
# статусы
email_verified = _to_bool(qp.get("email_verified"))
if email_verified is not None:
qs = qs.filter(email_verified=email_verified)
active = _to_bool(qp.get("active"))
if active is not None:
qs = qs.filter(is_active=active)
# реально активен
actually = _to_bool(qp.get("actually_active"))
if actually is True:
qs = qs.filter(
Q(email_verified=True)
& (
Q(last_action_code__isnull=True, is_active=True)
| ~Q(last_action_code=ACTION_DISMISSED)
)
)
elif actually is False:
qs = qs.exclude(
Q(email_verified=True)
& (
Q(last_action_code__isnull=True, is_active=True)
| ~Q(last_action_code=ACTION_DISMISSED)
)
)
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
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)
@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
ser = self.get_serializer(instance, data=request.data, partial=True)
ser.is_valid(raise_exception=True)
vd = dict(ser.validated_data)
# Бизнес-валидация каналов связи
if all(k not in vd for k in ("whatsapp", "telegram", "wechat")):
new_whatsapp = vd.get("whatsapp", instance.whatsapp)
new_telegram = vd.get("telegram", instance.telegram)
new_wechat = vd.get("wechat", instance.wechat)
if not (new_whatsapp or new_telegram or new_wechat):
return Response(
{
"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."
},
status=400,
)
svc = DirectoryService()
# LDAP-часть
ldap_keys = {"first_name", "last_name", "email", "phone_number", "is_active"}
ldap_changes = {k: vd.pop(k) for k in list(vd.keys()) if k in ldap_keys}
svc_changes = dict(ldap_changes)
pos_key_present = ("position" in request.data) or (
"position_id" in request.data
)
if pos_key_present:
pos_raw = (
request.data.get("position")
if "position" in request.data
else request.data.get("position_id")
)
svc_changes["position"] = pos_raw # тут может быть None — так и надо
vd.pop("position", None)
vd.pop("position_id", None)
move_to_department_dn = request.data.get("department_dn")
group_cns = request.data.get("group_cns")
avatar_file = ser.validated_data.get("avatar")
if avatar_file and hasattr(avatar_file, "read"):
try:
svc_changes["avatar_bytes"] = avatar_file.read()
except Exception:
pass
if svc_changes or move_to_department_dn or group_cns is not None:
try:
instance = svc.update_user(
instance,
changes=svc_changes,
group_cns=group_cns if group_cns is not None else None,
move_to_department_dn=move_to_department_dn,
)
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
return Response(
{"detail": str(e)},
status=502 if isinstance(e, DirectoryLdapError) else 500,
)
# DB-only
if vd:
ser_db = self.get_serializer(instance, data=vd, partial=True)
ser_db.is_valid(raise_exception=True)
ser_db.save()
data = ser_db.data
else:
data = self.get_serializer(instance).data
return Response(data, status=200)
@action(detail=True, methods=["post"])
def add_skill(self, request, pk=None):
"""
body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }
"""
emp = self.get_object()
sid = request.data.get("skill_id")
sname = (request.data.get("skill_name") or "").strip()
sk = None
if sid:
sk = Skill.objects.filter(pk=sid).first()
if not sk and sname:
sk = Skill.objects.filter(
name__iexact=sname
).first() or Skill.objects.create(name=sname)
if not sk:
return Response({"detail": "Навык не найден/не указан"}, status=400)
emp.skills.add(sk)
return Response(
{"ok": True, "skill": {"id": sk.id, "name": sk.name}}, status=200
)
@action(detail=True, methods=["post"])
def remove_skill(self, request, pk=None):
"""
body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }
"""
emp = self.get_object()
sid = request.data.get("skill_id")
sname = (request.data.get("skill_name") or "").strip()
sk = None
if sid:
sk = Skill.objects.filter(pk=sid).first()
if not sk and sname:
sk = Skill.objects.filter(name__iexact=sname).first()
if not sk:
return Response({"detail": "Навык не найден"}, status=404)
emp.skills.remove(sk)
return Response(
{"ok": True, "removed": {"id": sk.id, "name": sk.name}}, status=200
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
if getattr(self, "action", None) in {"retrieve", "me"}:
ctx["include_actions"] = True
ctx["include_action_history"] = True
return ctx
def partial_update(self, request, *args, **kwargs):
"""Частичное обновление: сначала LDAP-совместимые поля → затем только DB-only.
Returns:
Response: Обновлённые данные сотрудника.
"""
emp = self.get_object()
ser = self.get_serializer(emp, data=request.data, partial=True)
ser.is_valid(raise_exception=True)
vd = dict(ser.validated_data)
svc = DirectoryService()
# --- LDAP часть ---
ldap_keys = {"first_name", "last_name", "email", "phone_number", "is_active"}
ldap_changes = {k: vd.pop(k) for k in list(vd.keys()) if k in ldap_keys}
svc_changes = dict(ldap_changes)
pos_key_present = ("position" in request.data) or (
"position_id" in request.data
)
if pos_key_present:
pos_raw = (
request.data.get("position")
if "position" in request.data
else request.data.get("position_id")
)
svc_changes["position"] = pos_raw # тут может быть None — так и надо
vd.pop("position", None)
vd.pop("position_id", None)
move_to_department_dn = request.data.get("department_dn")
group_cns = request.data.get("group_cns") # список строк/None
# avatar → bytes (если разрешено править аватар в LDAP)
avatar_file = ser.validated_data.get("avatar")
if avatar_file and hasattr(avatar_file, "read"):
try:
svc_changes["avatar_bytes"] = avatar_file.read()
except Exception:
pass
if 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,
)
# --- DB-only часть ---
# Обновляем ТОЛЬКО оставшиеся поля, чтобы не перезаписать то, что уже сделал сервис
if vd:
ser_db = self.get_serializer(emp, data=vd, partial=True)
ser_db.is_valid(raise_exception=True)
ser_db.save()
data = ser_db.data
else:
data = self.get_serializer(emp).data
return Response(data, status=200)
class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
"""
/api/v1/positions/
GET list/retrieve — аутентифицированные
POST/PUT/PATCH/DEL — staff/superuser ИЛИ пользователь с model perms
Экшены:
POST /{id}/set-groups
POST /{id}/add-groups
POST /{id}/remove-groups
GET /{id}/permissions
"""
queryset = Position.objects.all().prefetch_related("groups")
serializer_class = PositionSerializer
pagination_class = None
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = ["name", "id"]
ordering = ["name"]
history_diff_fields = ["name", "description"]
permission_classes = [AdminOrActionOrModelPerms]
required_perms_by_action = {
"set_groups": "employees.assign_position_groups",
"add_groups": "employees.assign_position_groups",
"remove_groups": "employees.assign_position_groups",
}
def _validate_groups_payload(self, request):
ids = request.data.get("groups")
if not isinstance(ids, list):
return None, Response(
{"detail": "Поле 'groups' должно быть списком id"}, status=400
)
qs = Group.objects.filter(id__in=ids)
if qs.count() != len(set(ids)):
return None, Response({"detail": "Некоторые группы не найдены"}, status=400)
return qs, None
def get_permissions(self):
"""Возвращает пермишены для текущего экшена.
Returns:
list: Инスタнсы DRF-permission'ов.
"""
if self.action in {"list", "retrieve", "permissions"}:
return [IsAuthenticated()]
if self.action == "create":
return [AdminOrActionOrModelPerms()]
return [AdminOrActionOrModelPerms()]
@action(detail=True, methods=["post"])
def set_groups(self, request, pk=None):
pos = self.get_object()
qs, err = self._validate_groups_payload(request)
if err:
return err
pos.groups.set(qs)
err2 = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
if err2:
return err2
return Response(
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))}
)
@action(detail=True, methods=["post"])
def add_groups(self, request, pk=None):
pos = self.get_object()
qs, err = self._validate_groups_payload(request)
if err:
return err
pos.groups.add(*qs)
err2 = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
if err2:
return err2
return Response(
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))}
)
@action(detail=True, methods=["post"])
def remove_groups(self, request, pk=None):
pos = self.get_object()
qs, err = self._validate_groups_payload(request)
if err:
return err
pos.groups.remove(*qs)
err2 = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
if err2:
return err2
return Response(
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))}
)
@action(detail=True, methods=["get"])
def permissions(self, request, pk=None):
pos = self.get_object()
perms = (
Permission.objects.filter(group__positions=pos)
.select_related("content_type")
.distinct()
)
data = [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
"app": p.content_type.app_label,
"model": p.content_type.model,
}
for p in perms
]
return Response({"count": len(data), "results": data}, status=200)
def perform_create(self, serializer):
pos = serializer.save()
err = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
if err:
# позиция создана в БД, но LDAP не синкнулся — возвращаем 502
raise Exception(
err.data["detail"]
) # или пропустите исключение, если не хотите рвать транзакцию
def perform_update(self, serializer):
pos = serializer.save()
err = _ldap_try(lambda: DirectoryService().reconcile_position(pos))
if err:
# позиция обновлена в БД; LDAP можно синкнуть повторно позднее
pass
def perform_destroy(self, instance):
# best-effort: не блокируем удаление позиции из БД из-за LDAP
_ldap_try(lambda: DirectoryService().delete_position_group(instance))
return super().perform_destroy(instance)
class DepartmentRoleViewSet(viewsets.ModelViewSet):
"""
Роли отдела:
- list/retrieve с фильтром ?department=<id>
- create/update/destroy → требуется право DeptPerm.ASSIGN_ROLE в рамках отдела роли
- GET /department-roles/perm_choices/
- GET /department-roles/{id}/perms/
- POST /department-roles/{id}/set_perms (ids или codes; полная замена)
"""
queryset = (
DepartmentRole.objects.select_related("department")
.prefetch_related("scoped_permissions")
.all()
)
serializer_class = DepartmentRoleSerializer
class AssignRolePerm(AdminOrDeptAllowed):
"""Право на назначение ролей участникам отдела."""
required_code = DeptPerm.ASSIGN_ROLE
# стабильная сортировка для листинга
ordering_fields = ("name", "id")
ordering = ("name", "id")
def get_permissions(self):
if self.action in {
"create",
"update",
"partial_update",
"destroy",
"set_perms",
}:
return [self.AssignRolePerm()]
return [IsAuthenticated()]
def get_queryset(self):
"""
Фильтрация по отделу и стабильный порядок.
"""
qs = super().get_queryset()
dept = self.request.query_params.get("department")
if dept:
qs = qs.filter(department_id=dept)
ord_param = self.request.query_params.get("ordering")
if ord_param in {"name", "-name", "id", "-id"}:
# стабильный тай-брейк по id
qs = qs.order_by(
ord_param, "id" if not ord_param.startswith("-") else "-id"
)
else:
qs = qs.order_by(*self.ordering)
return qs
@action(detail=False, methods=["get"])
def perm_choices(self, request):
"""
Возвращает справочник скоуп-прав для ролей отдела из DeptPerm.CHOICES.
Если каких-то записей нет в БД, создаёт их (идемпотентно).
"""
data = _ensure_department_permissions()
return Response({"count": len(data), "results": data}, status=200)
@action(detail=True, methods=["get"])
def perms(self, request, pk=None):
"""
Возвращает список прав, назначенных данной роли (отсортирован по коду).
"""
role = self.get_object()
data = [
{"id": p.id, "code": p.code, "name": p.name}
for p in role.scoped_permissions.order_by("code")
]
return Response({"count": len(data), "results": data}, status=200)
@action(detail=True, methods=["post"])
def set_perms(self, request, pk=None):
"""
Полностью заменяет набор прав у роли.
Тело запроса:
- либо "permission_ids": [1,2,3]
- либо "permission_codes": ["manage_department", ...]
"""
role = self.get_object()
ids = request.data.get("permission_ids") or []
codes = request.data.get("permission_codes") or []
if isinstance(ids, list) and ids:
# валидация набора id
ids_int = {int(i) for i in ids if str(i).isdigit()}
qs = DepartmentPermission.objects.filter(id__in=ids_int)
if qs.count() != len(ids_int):
return Response(
{"detail": "Некоторые permission_ids не найдены."}, status=400
)
elif isinstance(codes, list) and codes:
# валидация набора кодов
codes_set = set(codes)
qs = DepartmentPermission.objects.filter(code__in=codes_set)
if qs.count() != len(codes_set):
return Response(
{"detail": "Некоторые permission_codes не найдены."}, status=400
)
else:
qs = DepartmentPermission.objects.none()
role.scoped_permissions.set(list(qs))
ser = self.get_serializer(role)
return Response(ser.data, status=200)
class SkillViewSet(viewsets.ModelViewSet):
"""
/api/v1/skills/
- GET list/retrieve — IsAuthenticated
- POST (create) — IsAuthenticated (любой авторизованный может создавать новые навыки)
- PATCH/PUT — staff/superuser ИЛИ perm employees.change_skill
- DELETE — staff/superuser ИЛИ perm employees.delete_skill
Экшены:
- POST /skills/bulk_create — как create (любой авторизованный)
- POST /skills/merge — объединить source->target (perm employees.change_skill)
body: {
"source_id": 1,
"target_id": 2,
"reassign": true, # переназначить сотрудникам source -> target (по умолч. true)
"delete_source": true # удалить исходный навык после переназначения (по умолч. true)
}
"""
queryset = Skill.objects.all().order_by("name")
serializer_class = SkillSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
ordering_fields = ["name", "id"]
ordering = ["name"]
pagination_class = None
required_perms_by_action = {
"merge": "employees.change_skill",
}
def get_permissions(self):
if self.action in ("create", "bulk_create", "list", "retrieve"):
return [IsAuthenticated()]
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
@action(detail=False, methods=["post"])
def bulk_create(self, request):
names = request.data.get("names")
if not isinstance(names, list) or not names:
return Response(
{"detail": "Поле 'names' должно быть непустым списком строк"},
status=400,
)
# нормализация: trim + отбраковка пустых, дедуп (case-insensitive)
cleaned = []
seen = set()
for n in names:
if not isinstance(n, str):
continue
s = n.strip()
if not s:
continue
key = s.lower()
if key in seen:
continue
seen.add(key)
cleaned.append(s)
# отфильтруем уже существующие (по точному совпадению имени)
existing = set(
Skill.objects.filter(name__in=cleaned).values_list("name", flat=True)
)
to_create = [Skill(name=n) for n in cleaned if n not in existing]
Skill.objects.bulk_create(to_create, ignore_conflicts=True)
created = Skill.objects.filter(
name__in=[n for n in cleaned if n not in existing]
).order_by("name")
return Response(
{
"created_count": created.count(),
"created": SkillSerializer(created, many=True).data,
},
status=201,
)
@action(detail=False, methods=["post"])
@transaction.atomic
def merge(self, request):
"""
Заменяет навык source на target, опционально переносит всех сотрудников и удаляет source.
Требуется право employees.change_skill.
"""
sid = request.data.get("source_id")
tid = request.data.get("target_id")
reassign = bool(request.data.get("reassign", True))
delete_source = bool(request.data.get("delete_source", True))
if not sid or not tid:
return Response({"detail": "source_id и target_id обязательны"}, status=400)
try:
sid = int(sid)
tid = int(tid)
except (TypeError, ValueError):
return Response(
{"detail": "source_id и target_id должны быть числами"}, status=400
)
if sid == tid:
return Response(
{"detail": "source_id и target_id не должны совпадать"}, status=400
)
try:
source = Skill.objects.get(pk=sid)
target = Skill.objects.get(pk=tid)
except Skill.DoesNotExist:
return Response({"detail": "Skill не найден"}, status=404)
moved_count = 0
if reassign:
# переназначить всем сотрудникам: source -> target
qs = Employee.objects.filter(skills=source).only("id").distinct()
for emp in qs:
emp.skills.add(target)
emp.skills.remove(source)
moved_count += 1
# удалить исходный навык при необходимости
if delete_source:
source.delete()
return Response(
{
"ok": True,
"source_id": sid,
"target_id": tid,
"reassigned_employees": moved_count,
"source_deleted": bool(delete_source),
},
status=200,
)
class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
"""
/api/v1/employee-actions/
- GET list/retrieve — IsAuthenticated
- POST create — staff/superuser ИЛИ perm employees.add_employeeaction
- PATCH/PUT update — staff/superuser ИЛИ perm employees.change_employeeaction
- DELETE destroy — staff/superuser ИЛИ perm employees.delete_employeeaction
Фильтры:
?employee=<id> — по сотруднику
?date_from=ISO — >=
?date_to=ISO — <=
"""
serializer_class = EmployeeActionSerializer
permission_classes = [IsAuthenticated]
filter_backends = [filters.OrderingFilter]
ordering_fields = ["date", "id"]
ordering = ["-date"]
pagination_class = None
history_diff_fields = ["action", "date", "comment", "extra", "employee_id"]
def get_queryset(self):
qs = EmployeeAction.objects.select_related("employee").order_by(*self.ordering)
qp = self.request.query_params
emp = qp.get("employee")
if emp:
try:
emp_id = int(emp)
qs = qs.filter(employee_id=emp_id)
except (TypeError, ValueError):
return EmployeeAction.objects.none()
df = qp.get("date_from")
dt = qp.get("date_to")
if df:
try:
qs = qs.filter(date__gte=df)
except Exception:
pass
if dt:
try:
qs = qs.filter(date__lte=dt)
except Exception:
pass
return qs
# --- права на запись через модельные пермишены ---
def get_permissions(self):
if self.action == "create":
self.required_perm_code = "employees.add_employeeaction"
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
if self.action in ("update", "partial_update"):
self.required_perm_code = "employees.change_employeeaction"
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
if self.action == "destroy":
self.required_perm_code = "employees.delete_employeeaction"
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
self.required_perm_code = None
return super().get_permissions()
# --- бизнес-эффекты после записи события ---
def _apply_effects(self, action_obj: EmployeeAction):
emp = action_obj.employee
if action_obj.action == ACTION_DISMISSED:
# деактивируем сотрудника и связи
EmployeeDepartment.objects.filter(employee=emp, is_active=True).update(
is_active=False, date_to=timezone.now().date()
)
if emp.is_active:
emp.is_active = False
emp.save(update_fields=["is_active"])
else:
# любое иное событие делает сотрудника активным
if not emp.is_active:
emp.is_active = True
emp.save(update_fields=["is_active"])
@transaction.atomic
def perform_create(self, serializer):
obj = serializer.save()
self._apply_effects(obj)
@transaction.atomic
def perform_update(self, serializer):
obj = serializer.save()
self._apply_effects(obj)
class GroupViewSet(viewsets.ModelViewSet):
"""CRUD и LDAP-операции с группами.
Базовые маршруты:
GET /api/v1/groups/ — список (аутентифицированные)
GET /api/v1/groups/{id}/ — детальная
POST /api/v1/groups/ — создать (staff/superuser или с model perms)
PATCH /api/v1/groups/{id}/ — частичное обновление
DELETE /api/v1/groups/{id}/ — удалить
Экшены:
GET /api/v1/groups/{id}/permissions
POST /api/v1/groups/{id}/set-permissions
POST /api/v1/groups/{id}/add-permissions
POST /api/v1/groups/{id}/remove-permissions
POST /api/v1/groups/{id}/rename
POST /api/v1/groups/{id}/set-description
GET /api/v1/groups/{id}/members
POST /api/v1/groups/{id}/add-members
POST /api/v1/groups/{id}/remove-members
POST /api/v1/groups/{id}/replace-members
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
pagination_class = None
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
ordering_fields = ["name", "id"]
ordering = ["name"]
permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms]
required_perms_by_action = {
"set_permissions": "employees.assign_group_permissions",
"add_permissions": "employees.assign_group_permissions",
"remove_permissions": "employees.assign_group_permissions",
"rename": "employees.assign_group_permissions",
"set_description": "employees.assign_group_permissions",
"add_members": "employees.assign_group_permissions",
"remove_members": "employees.assign_group_permissions",
"replace_members": "employees.assign_group_permissions",
}
# ---------- queryset ----------
def get_queryset(self):
qs = super().get_queryset()
member_raw = self.request.query_params.get(
"member"
) or self.request.query_params.get("member_id")
if member_raw is None:
return qs
try:
member_id = int(str(member_raw).strip())
except (TypeError, ValueError):
return qs.none() # некорректный id → пусто
# У Group нет явного поля на пользователя; используется обратная связь ManyToMany от пользователя:
# group.user_set (manager). Для ORM-фильтров используется related_query_name — в django.contrib.auth он "user".
# На всякий случай поддержим оба варианта: user__ и user_set__ (если кастомная модель могла переопределить имя).
try:
return qs.filter(
Q(user__id=member_id) | Q(user_set__id=member_id)
).distinct()
except FieldError:
# если вдруг один из путей не существует — пробуем максимально совместимый
try:
return qs.filter(user__id=member_id).distinct()
except FieldError:
try:
return qs.filter(user_set__id=member_id).distinct()
except FieldError:
return qs.none()
# ---------- helpers ----------
def _validate_permissions_payload(
self, request
) -> tuple[Optional[List[Permission]], Optional[Response]]:
"""Валидирует payload с ID permissions.
Args:
request (Request): DRF Request с полем 'permissions' (list[int]).
Returns:
tuple[Optional[List[Permission]], Optional[Response]]: (список Permission или None, Response с 400 или None)
Raises:
TypeError: Если элементы 'permissions' не приводимы к int.
"""
ids = request.data.get("permissions")
if not isinstance(ids, list):
return None, Response(
{"detail": "Поле 'permissions' должно быть списком id"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
pid_list = [int(x) for x in ids]
except (TypeError, ValueError):
return None, Response(
{"detail": "Список 'permissions' должен содержать целые числа"},
status=status.HTTP_400_BAD_REQUEST,
)
qs = Permission.objects.filter(id__in=pid_list)
if qs.count() != len(set(pid_list)):
return None, Response(
{"detail": "Некоторые permissions не найдены"},
status=status.HTTP_400_BAD_REQUEST,
)
return list(qs), None
def _resolve_group_dn(self, grp: Group) -> Optional[str]:
"""Определяет DN группы по её CN.
Сначала пробует DirectoryService.group_find_dn, затем (fallback) find_group_dn.
Args:
grp (Group): Экземпляр Group.
Returns:
Optional[str]: DN группы или None, если не найдена.
"""
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.
Raises:
ValueError: Если не удалось получить ни одного DN.
"""
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:
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 uniq:
raise ValueError("Не переданы корректные member_dns или member_ids")
return uniq
def _dns_to_users(self, dns: list[str]) -> list[Employee]:
"""Маппит DN участников на локальных пользователей через LdapSyncState.
Args:
dns (list[str]): Список DN.
Returns:
list[Employee]: Пользователи, найденные по LdapSyncState(model='employee', ldap_dn IN dns).
"""
if not dns:
return []
sub = LdapSyncState.objects.filter(
model="employee", object_pk=OuterRef("pk")
).values_list("ldap_dn", flat=True)[:1]
return list(
Employee.objects.annotate(ldap_dn=Subquery(sub))
.filter(ldap_dn__in=dns)
.only("id")
)
# ---------- override CRUD: LDAP → DB ----------
def create(self, request, *args, **kwargs) -> Response:
"""Создаёт LDAP-группу, затем запись Group в БД и (опционально) назначает permissions.
Body (частично):
name (str): CN группы (обязателен)
ldap_parent_dn (str): контейнер, по умолчанию settings.LDAP_GROUPS_BASE
ldap_description (str|None)
ldap_scope (str): 'global'|... (по умолчанию 'global')
ldap_security (bool): безопасная группа (по умолчанию True)
permissions (list[int]): список id Django permissions (необязателен)
Returns:
Response: 201 с сериализованной группой или ошибка 4xx/5xx.
Raises:
ValidationError: При ошибке сериализатора.
Exception: Пробрасывается как 502/500 в зависимости от источника ошибки.
"""
ser = self.get_serializer(data=request.data)
ser.is_valid(raise_exception=True)
name: str = ser.validated_data["name"]
parent_dn = request.data.get("ldap_parent_dn") or getattr(
settings, "LDAP_GROUPS_BASE", None
)
description = request.data.get("ldap_description")
scope = request.data.get("ldap_scope", "global")
security_enabled = bool(request.data.get("ldap_security", True))
svc = DirectoryService()
try:
svc.group_create(
cn=name,
parent_dn=parent_dn,
description=description,
scope=scope,
security_enabled=security_enabled,
)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
try:
grp = Group.objects.create(name=name)
perms = ser.validated_data.get("permissions")
if perms:
grp.permissions.set(perms)
except Exception as e:
# откат LDAP-создания
try:
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__")
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"}, status=status.HTTP_404_NOT_FOUND
)
svc = DirectoryService()
try:
if new_name and new_name != grp.name:
dn = svc.group_rename(dn, new_name)
if new_desc != "__NO_CHANGE__":
svc.group_set_description(dn, (new_desc or None))
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
if new_name and new_name != grp.name:
grp.name = new_name
grp.save(update_fields=["name"])
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs) -> Response:
"""Удаляет LDAP-группу (если найдена), затем запись Group в БД.
Query params:
force_db (bool): При ошибке удаления в LDAP всё равно удалить запись БД.
"""
grp = self.get_object()
dn = self._resolve_group_dn(grp)
force_db = str(request.query_params.get("force_db", "")).lower() in {
"1",
"true",
"yes",
}
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-синк каталога."""
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
)
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)
grp.name = new_name
grp.save(update_fields=["name"])
return Response({"ok": True, "name": grp.name}, status=status.HTTP_200_OK)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
@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()
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()
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()
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,
)
@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()
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,
)
@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()
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,
)