refactor(employees): split monolithic views.py (4593 lines) into views/ package

- Split into 9 modules by ViewSet: departments, employees, positions,
  roles, skills, actions, groups, auth, _helpers
- Created views/__init__.py with re-exports for backward compatibility
- Created employees/permissions.py re-export shim (views/ is now a
  sub-package, so ..permissions resolves to employees.permissions)
- Fixed api/auth/serializers.py import: PHONE_FIELD from _helpers,
  _normalize_phone from employees.utils
- Removed ~250+ debug print()/logger.info() statements from
  departments.py and employees.py
- Removed dead _with_ldap_service() decorator from helpers
- Removed unreachable code at end of GroupViewSet.replace_members

manage.py check: System check identified no issues (0 silenced)
This commit is contained in:
igor_nadein 2026-02-16 16:18:03 +03:00
parent 6164ee038a
commit 8efcabf530
13 changed files with 3810 additions and 4593 deletions

View File

@ -1,6 +1,7 @@
# api/auth/serializers.py
from api.v1.employees.views import PHONE_FIELD, _normalize_phone
from api.v1.employees.views._helpers import PHONE_FIELD
from django.contrib.auth import get_user_model
from employees.utils import _normalize_phone
from employees.models import Employee
from rest_framework import serializers
from rest_framework.exceptions import AuthenticationFailed

View File

@ -0,0 +1,20 @@
# flake8: noqa: F401
"""Реэкспорт permissions из api.v1.permissions для обратной совместимости.
Модуль views/ стал пакетом, и относительный импорт ``..permissions``
из его подмодулей резолвится сюда (api.v1.employees.permissions).
"""
from api.v1.permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
IsSelfOrStaff,
has_dept_perm,
)
__all__ = [
"AdminOrActionOrModelPerms",
"AdminOrDeptAllowed",
"IsSelfOrStaff",
"has_dept_perm",
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
# flake8: noqa
"""
Пакет views для employees API.
Разбит из монолитного views.py на отдельные модули по ViewSet/области ответственности.
"""
from .actions import EmployeeActionViewSet
from .auth import RegisterAPIView, ResendEmailAPIView, VerifyEmailAPIView
from .departments import DepartmentViewSet
from .employees import EmployeeViewSet
from .groups import GroupViewSet
from .positions import PositionViewSet
from .roles import DepartmentRoleViewSet
from .skills import SkillViewSet
__all__ = [
"DepartmentViewSet",
"EmployeeViewSet",
"PositionViewSet",
"DepartmentRoleViewSet",
"SkillViewSet",
"EmployeeActionViewSet",
"GroupViewSet",
"RegisterAPIView",
"VerifyEmailAPIView",
"ResendEmailAPIView",
]

View File

@ -0,0 +1,115 @@
"""Общие хелперы и миксины для employees views."""
from __future__ import annotations
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Q
from employees.ldap.errors import (
DirectoryDbError,
DirectoryLdapError,
DirectoryServiceError,
)
from employees.utils import _detect_phone_field
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
logger = logging.getLogger(__name__)
Employee = get_user_model()
PHONE_FIELD = _detect_phone_field()
def _is_ldap_enabled() -> bool:
"""Проверяет, включена ли интеграция с 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
)
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 Exception:
pass
if q_to:
try:
qs = qs.filter(history_date__lte=q_to)
except Exception:
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})

View File

@ -0,0 +1,264 @@
"""EmployeeActionViewSet — кадровые события (приём, увольнение и т.д.)."""
from __future__ import annotations
import logging
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from employees.constants import ACTION_DISMISSED
from employees.ldap.directory_service import DirectoryService
from employees.ldap.errors import (
DirectoryDbError,
DirectoryLdapError,
DirectoryServiceError,
)
from employees.models import (
EmployeeAction,
EmployeeDepartment,
LdapSyncState,
)
from rest_framework import filters, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..permissions import AdminOrActionOrModelPerms
from ..serializers import EmployeeActionSerializer
from ._helpers import HistoryActionMixin, _is_ldap_enabled
logger = logging.getLogger(__name__)
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:
# Сначала деактивируем сотрудника
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
if ldap_enabled:
try:
svc = DirectoryService()
has_ldap_dn = LdapSyncState.objects.filter(
model="employee", object_pk=str(emp.pk), ldap_dn__isnull=False
).exists()
if has_ldap_dn:
svc.update_user(emp, changes={"is_active": False})
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:
logger.error(
f"Failed to remove dismissed employee from department: "
f"employee_id={emp.id}, department_id={emp_dept.department.id}, error={e}"
)
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,
)
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"
)
logger.info(
f"Dismissed employee without department moved to OU=Dismissed: "
f"employee_id={emp.id}, new_dn={new_dn}"
)
except Exception as e:
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:
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"])
if ldap_enabled:
try:
svc = DirectoryService()
has_ldap_dn = LdapSyncState.objects.filter(
model="employee", object_pk=str(emp.pk), ldap_dn__isnull=False
).exists()
if has_ldap_dn:
svc.update_user(emp, changes={"is_active": True})
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", ""
)
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)
new_dn = (
svc._user_service._move_user_to_base(
conn, current_dn, users_base
)
)
sync_state.touch(
ldap_dn=new_dn, sync_dir="ldap"
)
logger.info(
f"Restored employee moved from Dismissed to Users: "
f"employee_id={emp.id}, new_dn={new_dn}"
)
except Exception as e:
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:
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)

View File

@ -0,0 +1,321 @@
"""Auth-related views: регистрация, подтверждение email, повторная отправка кода."""
from __future__ import annotations
import logging
from common.emails import send_templated_mail
from datetime import timedelta
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from employees.ldap.directory_service import DirectoryService, DirectoryUserDTO
from employees.ldap.errors import (
DirectoryDbError,
DirectoryLdapError,
DirectoryServiceError,
)
from employees.models import Position, Skill
from employees.utils import _normalize_phone
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from ..serializers import EmailSerializer, EmailVerifySerializer, RegisterSerializer
from ._helpers import Employee, _is_ldap_enabled
logger = logging.getLogger(__name__)
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 запись не найдена - активируем только в БД
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 недоступен, всё равно активируем в БД
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:
# При ошибке сервиса тоже активируем в БД
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):
# Логируем входящие данные для диагностики
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,
)

View File

@ -0,0 +1,751 @@
"""DepartmentViewSet — CRUD отделов + действия (set_head, add_member и т.д.)."""
from __future__ import annotations
import logging
import traceback
from typing import Any, Dict
from django.db import transaction
from django.db.models import (
Case,
Count,
Exists,
F,
IntegerField,
OuterRef,
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 employees.ldap.directory_service import (
DirectoryDepartmentDTO,
DirectoryService,
)
from employees.ldap.errors import (
DirectoryDbError,
DirectoryLdapError,
DirectoryServiceError,
)
from employees.models import (
Department,
DepartmentRole,
DeptPerm,
EmployeeDepartment,
RoleAssignment,
)
from employees.utils import (
_build_links_for_dept,
_head_choices_for_dept,
_perm_choices_synced,
_validate_head_active,
)
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
has_dept_perm,
)
from ..serializers import (
AddMemberInput,
DepartmentBriefSerializer,
DepartmentRoleSerializer,
DepartmentSerializer,
EmployeeBriefSerializer,
RemoveMemberInput,
SetHeadInput,
SetMemberRoleInput,
)
from ._helpers import _is_ldap_enabled
logger = logging.getLogger(__name__)
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
)
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
# --- частичное изменение ---
def partial_update(self, request, *args, **kwargs) -> Response:
"""Частичный апдейт отдела через сервисный слой (LDAP → DB)."""
instance = self.get_object()
data: Dict[str, Any] = request.data
ldap_enabled = _is_ldap_enabled()
svc = DirectoryService() if ldap_enabled else None
# --- HEAD ---
if any(k in data for k in ("head", "head_id")):
raw_desired = data.get("head_id", data.get("head", None))
try:
if isinstance(raw_desired, str) and raw_desired.strip().lower() in {
"",
"null",
"none",
}:
desired_head_id = None
else:
try:
desired_head_id = int(raw_desired) if raw_desired is not None else None
except (TypeError, ValueError):
raise ValueError("head_id должен быть целым числом или null")
except ValueError as e:
return Response(
{"head_id": [str(e)]}, status=status.HTTP_400_BAD_REQUEST
)
# --- права ---
perm = self.ChangeHeadPerm()
has_perm = perm.has_permission(
request, self
) and perm.has_object_permission(request, self, instance)
if not has_perm:
return Response(
{"detail": "Forbidden"}, status=status.HTTP_403_FORBIDDEN
)
# Если указали того же руководителя — пропустим лишнюю работу
if (desired_head_id or None) != (instance.head_id or None):
# --- валидация кандидата ---
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)
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,
)
if not ok:
return Response(errs, status=status.HTTP_400_BAD_REQUEST)
# --- LDAP → DB ---
if ldap_enabled:
try:
instance = svc.set_head(instance, new_head)
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
)
)
return Response({"detail": str(e)}, status=code)
else:
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"])
# --- NAME / DESCRIPTION ---
changes: Dict[str, Any] = {}
for k in ("name", "description"):
if k in data:
changes[k] = data.get(k)
if changes:
if ldap_enabled:
try:
instance = svc.update_department(instance, changes)
except DirectoryLdapError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
else:
for k, v in changes.items():
setattr(instance, k, v)
instance.save(update_fields=list(changes.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)."""
dept = self.get_object()
payload = SetHeadInput(data=request.data)
payload.is_valid(raise_exception=True)
head_id = payload.validated_data.get("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
)
# --- права ---
perm = self.ChangeHeadPerm()
has_perm = perm.has_permission(request, self) and perm.has_object_permission(
request, self, dept
)
if not has_perm:
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)
)
ok, errs = _validate_head_active(
dept, head_id, require_email_verified=require_verified
)
if not ok:
return Response(errs, status=status.HTTP_400_BAD_REQUEST)
# --- применение ---
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
svc = DirectoryService()
try:
dept = svc.set_head(dept, new_head)
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
)
)
return Response({"detail": str(e)}, status=status_code)
else:
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"])
return Response(self.get_serializer(dept).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
@transaction.atomic
def set_member_role(self, request, pk=None):
"""
Назначает/снимает РОЛЬ сотруднику в контексте отдела.
Если сотрудник — член отдела, обновляет EmployeeDepartment.role.
Если нет — создаёт/обновляет RoleAssignment.
"""
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,
)
# Проверяем, является ли сотрудник членом отдела
link = EmployeeDepartment.objects.filter(
employee_id=emp_id, department_id=dept.id
).first()
via_assignment = False
if link:
# Сотрудник — член отдела: обновляем роль в линке
svc = DirectoryService() if _is_ldap_enabled() else None
if svc:
try:
svc.set_member_role(dept, employee, role)
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:
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
if role:
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:
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
dept_service.assign_role(employee, role, request.user)
except Exception as e:
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():
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:
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 = 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:
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:
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:
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:
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:
"""Вернёт список отделов, доступных текущему пользователю."""
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."""
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:
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:
dto = DirectoryDepartmentDTO(
name=ser.validated_data["name"],
description=ser.validated_data.get("description", ""),
head=head,
)
svc = DirectoryService()
try:
dept = svc.create_department(dto)
return Response(
self.get_serializer(dept).data, status=status.HTTP_201_CREATED
)
except DirectoryLdapError as e:
return Response({"detail": str(e)}, status=502)
except DirectoryDbError as e:
return Response({"detail": str(e)}, status=500)
else:
dept = Department.objects.create(
name=ser.validated_data["name"],
description=ser.validated_data.get("description", ""),
head=head,
)
return Response(
self.get_serializer(dept).data, status=status.HTTP_201_CREATED
)
def destroy(self, request, *args, **kwargs):
"""Удаляет отдел: сначала в LDAP → затем из БД."""
dept = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
svc = DirectoryService()
try:
svc.delete_department(dept)
return Response(status=204)
except DirectoryLdapError as e:
return Response({"detail": str(e)}, status=502)
except DirectoryDbError as e:
return Response({"detail": str(e)}, status=500)
else:
dept.delete()
return Response(status=204)

View File

@ -0,0 +1,930 @@
"""EmployeeViewSet — CRUD сотрудников, профиль, навыки, LDAP-info, Excel-экспорт."""
from __future__ import annotations
import logging
import traceback
from common.emails import send_templated_mail
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import transaction
from django.db.models import (
Exists,
OuterRef,
Prefetch,
Q,
Subquery,
)
from django.shortcuts import get_object_or_404
from django.utils.crypto import get_random_string
from employees.constants import ACTION_DISMISSED
from employees.ldap.directory_service import DirectoryService, DirectoryUserDTO
from employees.ldap.errors import (
DirectoryDbError,
DirectoryLdapError,
DirectoryServiceError,
)
from employees.ldap.infrastructure.connections import _conn
from employees.models import (
Department,
EmployeeAction,
EmployeeDepartment,
LdapSyncState,
Position,
RoleAssignment,
Skill,
)
from employees.utils import _to_bool
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..permissions import AdminOrActionOrModelPerms, IsSelfOrStaff
from ..serializers import (
EmployeeListSerializer,
EmployeeSerializer,
SkillSerializer,
)
from ._helpers import Employee, _is_ldap_enabled
logger = logging.getLogger(__name__)
class EmployeeViewSet(viewsets.ModelViewSet):
"""
/api/v1/employees/
Доступ:
- GET list/retrieve — только аутентифицированные пользователи.
- POST (create) — только staff/superuser.
- PATCH/PUT/DELETE {id} — staff/superuser ИЛИ пользователи с модельными правами.
- GET/PATCH /employees/me/ — только аутентифицированные; PATCH правит профиль текущего пользователя.
- POST {id}/add_skill|remove_skill — требуется employees.manage_employee_skills.
Поиск: last_name, first_name, patronymic, email, phone_number
"""
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",
}
def get_permissions(self):
if self.action == "create":
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
if self.action in {
"update",
"partial_update",
"destroy",
"add_skill",
"remove_skill",
"ldap_info",
}:
return [IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)()]
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")
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()
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,
)
),
)
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_enabled = _is_ldap_enabled()
if ldap_enabled:
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,
avatar_bytes=avatar_bytes,
is_active=vd.get("is_active", True),
)
try:
user = DirectoryService().create_user(dto)
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:
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:
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)
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 — частичное обновление."""
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
try:
old_email = instance.email
# Удаляем пустое поле avatar ДО валидации
data_for_serializer = request.data
if "avatar" in request.data:
avatar_value = request.data.get("avatar")
if avatar_value == "":
data_for_serializer = {
k: v for k, v in request.data.items() if k != "avatar"
}
ser = self.get_serializer(
instance, data=data_for_serializer, partial=True
)
ser.is_valid(raise_exception=True)
vd = dict(ser.validated_data)
# Проверяем изменение email
new_email = vd.get("email")
email_changed = new_email and new_email.lower() != old_email.lower()
# Бизнес-валидация каналов связи
if all(k not in vd for k in ("whatsapp", "telegram", "wechat")):
new_whatsapp = vd.get("whatsapp", instance.whatsapp)
new_telegram = vd.get("telegram", instance.telegram)
new_wechat = vd.get("wechat", instance.wechat)
if not (new_whatsapp or new_telegram or new_wechat):
return Response(
{
"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."
},
status=400,
)
ldap_enabled = _is_ldap_enabled()
# LDAP-часть
ldap_keys = {
"first_name",
"last_name",
"email",
"phone_number",
"is_active",
}
ldap_changes = {k: vd.pop(k) for k in list(vd.keys()) if k in ldap_keys}
svc_changes = dict(ldap_changes)
pos_key_present = ("position" in request.data) or (
"position_id" in request.data
)
if pos_key_present:
pos_raw = (
request.data.get("position")
if "position" in request.data
else request.data.get("position_id")
)
svc_changes["position"] = pos_raw
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") or request.FILES.get(
"avatar"
)
if avatar_file and hasattr(avatar_file, "read"):
try:
svc_changes["avatar_bytes"] = avatar_file.read()
except Exception:
pass
# Проверяем, есть ли у пользователя ldap_dn
has_ldap_dn = False
if ldap_enabled:
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)
if (
ldap_enabled
and has_ldap_dn
and (svc_changes or move_to_department_dn or group_cns is not None)
):
svc = DirectoryService()
try:
instance = svc.update_user(
instance,
changes=svc_changes,
group_cns=group_cns if group_cns is not None else None,
move_to_department_dn=move_to_department_dn,
)
except (
DirectoryLdapError,
DirectoryDbError,
DirectoryServiceError,
) as e:
return Response(
{"detail": str(e)},
status=502 if isinstance(e, DirectoryLdapError) else 500,
)
elif (not ldap_enabled or not has_ldap_dn) and svc_changes:
for k, v in svc_changes.items():
if k != "position" and k != "avatar_bytes":
setattr(instance, k, v)
elif k == "position":
instance.position_id = v
elif k == "avatar_bytes" and v:
filename = f"avatar_{instance.id}.jpg"
instance.avatar.save(
filename,
ContentFile(v),
save=False,
)
instance.save()
# DB-only
if "avatar" in vd:
vd.pop("avatar")
if vd:
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
except ValidationError as ve:
return Response(ve.detail, status=400)
else:
data = self.get_serializer(instance).data
# Сброс email_verified при изменении email
if email_changed:
instance.email_verified = False
instance.email_activation_code = get_random_string(6, "0123456789")
instance.save(update_fields=["email_verified", "email_activation_code"])
try:
send_templated_mail(
subject="Подтверждение нового email",
to=[instance.email],
template_base="emails/registration_verify_code",
context={
"code": instance.email_activation_code,
"user": instance,
},
)
except Exception:
pass
data["email_verified"] = False
return Response(data, status=200)
except Exception as e:
logger.error(f"[ME PATCH] FATAL ERROR: {e}", exc_info=True)
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/ — LDAP информация о сотруднике."""
from employees.ldap.repositories.ldap_repository import LdapRepository
emp = self.get_object()
force_refresh = request.query_params.get('force_refresh', '').lower() == 'true'
if not force_refresh and emp.username:
return Response(
{
"sAMAccountName": emp.username,
"cached": True,
},
status=status.HTTP_200_OK,
)
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,
)
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,
)
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."""
from datetime import datetime
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
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")
)
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()])
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 Exception:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column].width = adjusted_width
ws.freeze_panes = "A2"
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}"'
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."""
emp = self.get_object()
old_email = emp.email
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)
ser = self.get_serializer(emp, data=data, partial=True)
ser.is_valid(raise_exception=True)
vd = dict(ser.validated_data)
ldap_enabled = _is_ldap_enabled()
# Проверяем изменение email
new_email = vd.get("email")
email_changed = new_email and new_email.lower() != old_email.lower()
# --- LDAP часть ---
ldap_keys = {
"first_name",
"last_name",
"email",
"phone_number",
"is_active",
}
ldap_changes = {k: vd.pop(k) for k in list(vd.keys()) if k in ldap_keys}
svc_changes = dict(ldap_changes)
pos_key_present = ("position" in 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
vd.pop("position", None)
vd.pop("position_id", None)
move_to_department_dn = data.get("department_dn")
group_cns = data.get("group_cns")
# avatar → bytes
avatar_file = ser.validated_data.get("avatar")
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)
except Exception:
pass
# Проверяем, есть ли у пользователя ldap_dn
has_ldap_dn = False
if ldap_enabled:
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)
if ldap_enabled and has_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
vd.update(ldap_changes)
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,
)
if avatar_file and hasattr(avatar_file, "read"):
try:
avatar_bytes = avatar_file.read()
emp.avatar.save(
avatar_file.name,
ContentFile(avatar_bytes),
save=False,
)
vd.pop("avatar", None)
except Exception:
pass
# --- DB-only часть ---
if "avatar" in vd:
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:
return Response(exc.detail, status=400)
else:
data = self.get_serializer(emp).data
# Сброс email_verified при изменении email
if email_changed:
emp.email_verified = False
emp.email_activation_code = get_random_string(6, "0123456789")
emp.save(update_fields=["email_verified", "email_activation_code"])
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)

View File

@ -0,0 +1,689 @@
"""GroupViewSet — CRUD и LDAP-операции с группами."""
from __future__ import annotations
from typing import Any, List, Optional
from django.conf import settings
from django.contrib.auth.models import Group, Permission
from django.core.exceptions import FieldError
from django.db import transaction
from django.db.models import OuterRef, Q, Subquery
from employees.ldap.directory_service import DirectoryService
from employees.models import LdapSyncState
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..permissions import AdminOrActionOrModelPerms
from ..serializers import GroupSerializer
from ._helpers import Employee, _is_ldap_enabled
class GroupViewSet(viewsets.ModelViewSet):
"""CRUD и LDAP-операции с группами.
Базовые маршруты:
GET/POST /api/v1/groups/
GET/PATCH/DELETE /api/v1/groups/{id}/
Экшены: permissions, set-permissions, add-permissions, remove-permissions,
rename, set-description, members, add-members, remove-members, replace-members
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
pagination_class = None
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
ordering_fields = ["name", "id"]
ordering = ["name"]
permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms]
required_perms_by_action = {
"set_permissions": "employees.assign_group_permissions",
"add_permissions": "employees.assign_group_permissions",
"remove_permissions": "employees.assign_group_permissions",
"rename": "employees.assign_group_permissions",
"set_description": "employees.assign_group_permissions",
"add_members": "employees.assign_group_permissions",
"remove_members": "employees.assign_group_permissions",
"replace_members": "employees.assign_group_permissions",
}
# ---------- queryset ----------
def get_queryset(self):
qs = super().get_queryset()
member_raw = self.request.query_params.get(
"member"
) or self.request.query_params.get("member_id")
if member_raw is None:
return qs
try:
member_id = int(str(member_raw).strip())
except (TypeError, ValueError):
return qs.none()
try:
return qs.filter(
Q(user__id=member_id) | Q(user_set__id=member_id)
).distinct()
except FieldError:
try:
return qs.filter(user__id=member_id).distinct()
except FieldError:
try:
return qs.filter(user_set__id=member_id).distinct()
except FieldError:
return qs.none()
# ---------- helpers ----------
def _validate_permissions_payload(
self, request
) -> tuple[Optional[List[Permission]], Optional[Response]]:
"""Валидирует payload с ID permissions."""
ids = request.data.get("permissions")
if not isinstance(ids, list):
return None, Response(
{"detail": "Поле 'permissions' должно быть списком id"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
pid_list = [int(x) for x in ids]
except (TypeError, ValueError):
return None, Response(
{"detail": "Список 'permissions' должен содержать целые числа"},
status=status.HTTP_400_BAD_REQUEST,
)
qs = Permission.objects.filter(id__in=pid_list)
if qs.count() != len(set(pid_list)):
return None, Response(
{"detail": "Некоторые permissions не найдены"},
status=status.HTTP_400_BAD_REQUEST,
)
return list(qs), None
def _resolve_group_dn(self, grp: Group) -> Optional[str]:
"""Определяет DN группы по её CN."""
if not _is_ldap_enabled():
return None
base = getattr(settings, "LDAP_GROUPS_BASE", "") or None
svc = DirectoryService()
try:
return svc.group_find_dn(grp.name, bases=[base] if base else None)
except AttributeError:
return svc.find_group_dn(grp.name, bases=[base] if base else None)
def _members_payload_to_dns(self, payload: dict[str, Any]) -> list[str]:
"""Извлекает список DN участников из payload (member_dns|member_ids)."""
dns: list[str] = []
raw_dns = payload.get("member_dns") or []
if isinstance(raw_dns, list):
dns.extend([d.strip() for d in raw_dns if isinstance(d, str) and d.strip()])
ids = payload.get("member_ids") or []
if isinstance(ids, list) and ids:
if _is_ldap_enabled():
svc = DirectoryService()
dns.extend(
svc.employee_ids_to_dns([i for i in ids if isinstance(i, int)])
)
uniq, seen = [], set()
for d in dns:
if d and d not in seen:
uniq.append(d)
seen.add(d)
if not _is_ldap_enabled():
return []
if not uniq:
raise ValueError("Не переданы корректные member_dns или member_ids")
return uniq
def _dns_to_users(self, dns):
"""Маппит DN участников на локальных пользователей через LdapSyncState."""
if not dns:
return []
sub = LdapSyncState.objects.filter(
model="employee", object_pk=OuterRef("pk")
).values_list("ldap_dn", flat=True)[:1]
return list(
Employee.objects.annotate(ldap_dn=Subquery(sub))
.filter(ldap_dn__in=dns)
.only("id")
)
# ---------- override CRUD: LDAP → DB ----------
def create(self, request, *args, **kwargs) -> Response:
"""Создаёт LDAP-группу, затем запись Group в БД."""
ser = self.get_serializer(data=request.data)
ser.is_valid(raise_exception=True)
name: str = ser.validated_data["name"]
parent_dn = request.data.get("ldap_parent_dn") or getattr(
settings, "LDAP_GROUPS_BASE", None
)
description = request.data.get("ldap_description")
scope = request.data.get("ldap_scope", "global")
security_enabled = bool(request.data.get("ldap_security", True))
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
svc = DirectoryService()
try:
svc.group_create(
cn=name,
parent_dn=parent_dn,
description=description,
scope=scope,
security_enabled=security_enabled,
)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
try:
grp = Group.objects.create(name=name)
perms = ser.validated_data.get("permissions")
if perms:
grp.permissions.set(perms)
except Exception as e:
if ldap_enabled:
try:
svc = DirectoryService()
dn = None
try:
dn = svc.group_find_dn(
name, bases=[parent_dn] if parent_dn else None
)
except AttributeError:
dn = svc.find_group_dn(
name, bases=[parent_dn] if parent_dn else None
)
if dn:
svc.group_delete(dn)
except Exception:
pass
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
out = self.get_serializer(grp)
return Response(
out.data,
status=status.HTTP_201_CREATED,
headers=self.get_success_headers(out.data),
)
def partial_update(self, request, *args, **kwargs) -> Response:
"""Частичное обновление: сначала LDAP (rename/description), затем БД."""
grp = self.get_object()
new_name = request.data.get("name")
new_desc = request.data.get("ldap_description", "__NO_CHANGE__")
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
svc = DirectoryService()
try:
if new_name and new_name != grp.name:
dn = svc.group_rename(dn, new_name)
if new_desc != "__NO_CHANGE__":
svc.group_set_description(dn, (new_desc or None))
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
if new_name and new_name != grp.name:
grp.name = new_name
grp.save(update_fields=["name"])
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs) -> Response:
"""Удаляет LDAP-группу, затем запись Group в БД."""
grp = self.get_object()
force_db = str(request.query_params.get("force_db", "")).lower() in {
"1",
"true",
"yes",
}
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if dn:
try:
DirectoryService().group_delete(dn)
except Exception as e:
if not force_db:
return Response(
{"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY
)
return super().destroy(request, *args, **kwargs)
def list(self, request, *args, **kwargs) -> Response:
"""Список групп. Перед выдачей — мягкий LDAP-синк каталога."""
if _is_ldap_enabled():
try:
DirectoryService().sync_groups_catalog(throttle_seconds=60)
except Exception:
pass
return super().list(request, *args, **kwargs)
# ---------- Actions: Django permissions ----------
@action(detail=True, methods=["get"])
def permissions(self, request, pk=None) -> Response:
"""Permissions, привязанные к группе."""
grp = self.get_object()
perms = grp.permissions.select_related("content_type").distinct()
data = [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
"app": p.content_type.app_label,
"model": p.content_type.model,
}
for p in perms
]
return Response(
{"count": len(data), "results": data}, status=status.HTTP_200_OK
)
@action(detail=True, methods=["post"], url_path="set-permissions")
def set_permissions(self, request, pk=None) -> Response:
"""Полностью заменяет набор permissions у группы."""
grp = self.get_object()
qs, error = self._validate_permissions_payload(request)
if error:
return error
grp.permissions.set(qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"], url_path="add-permissions")
def add_permissions(self, request, pk=None) -> Response:
"""Добавляет permissions к группе."""
grp = self.get_object()
qs, error = self._validate_permissions_payload(request)
if error:
return error
grp.permissions.add(*qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"], url_path="remove-permissions")
def remove_permissions(self, request, pk=None) -> Response:
"""Удаляет указанные permissions у группы."""
grp = self.get_object()
qs, error = self._validate_permissions_payload(request)
if error:
return error
grp.permissions.remove(*qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=status.HTTP_200_OK,
)
# ---------- Actions: LDAP ----------
@action(detail=True, methods=["post"])
def rename(self, request, pk=None) -> Response:
"""Переименовывает LDAP-группу и синхронизирует имя в БД."""
grp = self.get_object()
new_name = (request.data.get("new_name") or "").strip()
if not new_name:
return Response(
{"detail": "new_name обязателен"}, status=status.HTTP_400_BAD_REQUEST
)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
svc = DirectoryService()
try:
svc.group_rename(dn, new_name)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
grp.name = new_name
grp.save(update_fields=["name"])
return Response({"ok": True, "name": grp.name}, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="set-description")
def set_description(self, request, pk=None) -> Response:
"""Устанавливает описание LDAP-группы."""
grp = self.get_object()
if not _is_ldap_enabled():
return Response({"ok": True}, status=status.HTTP_200_OK)
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"}, status=status.HTTP_404_NOT_FOUND
)
svc = DirectoryService()
try:
svc.group_set_description(dn, request.data.get("description"))
return Response({"ok": True}, status=status.HTTP_200_OK)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
@action(detail=True, methods=["get"])
def members(self, request, pk=None) -> Response:
"""Состав LDAP-группы."""
grp = self.get_object()
if not _is_ldap_enabled():
users = grp.user_set.all()
employees = [
{
"id": u.id,
"email": u.email,
"first_name": u.first_name,
"last_name": u.last_name,
}
for u in users
]
return Response(
{"dns": [], "employees": employees}, status=status.HTTP_200_OK
)
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"}, status=status.HTTP_404_NOT_FOUND
)
svc = DirectoryService()
try:
dns = svc.group_list_members(dn)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
employees = svc.employees_brief_by_dns(dns)
return Response({"dns": dns, "employees": employees}, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="add-members")
def add_members(self, request, pk=None) -> Response:
"""Добавляет участников в LDAP-группу и связывает в БД."""
grp = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
try:
member_dns = self._members_payload_to_dns(request.data)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
svc = DirectoryService()
try:
svc.group_add_members(dn, member_dns)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
users = self._dns_to_users(member_dns)
ok_user_ids = [u.id for u in users]
try:
with transaction.atomic():
grp.user_set.add(*users)
except Exception as e:
try:
svc.group_remove_members(dn, member_dns)
except Exception:
pass
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{
"ok": True,
"ldap_added": len(member_dns),
"db_added": len(users),
"ok_dns": member_dns,
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
else:
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
users = Employee.objects.filter(id__in=member_ids)
try:
with transaction.atomic():
grp.user_set.add(*users)
except Exception as e:
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"ldap_added": 0,
"db_added": len(users),
"ok_dns": [],
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"], url_path="remove-members")
def remove_members(self, request, pk=None) -> Response:
"""Удаляет участников из LDAP-группы и разрывает связи в БД."""
grp = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
try:
member_dns = self._members_payload_to_dns(request.data)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
svc = DirectoryService()
try:
svc.group_remove_members(dn, member_dns)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
users = self._dns_to_users(member_dns)
ok_user_ids = [u.id for u in users]
try:
with transaction.atomic():
grp.user_set.remove(*users)
except Exception as e:
try:
svc.group_add_members(dn, member_dns)
except Exception:
pass
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{
"ok": True,
"ldap_removed": len(member_dns),
"db_removed": len(users),
"ok_dns": member_dns,
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
else:
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
users = Employee.objects.filter(id__in=member_ids)
try:
with transaction.atomic():
grp.user_set.remove(*users)
except Exception as e:
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"ldap_removed": 0,
"db_removed": len(users),
"ok_dns": [],
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"], url_path="replace-members")
def replace_members(self, request, pk=None) -> Response:
"""Полностью заменяет состав LDAP-группы и синхронизирует M2M в БД."""
grp = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
dn = self._resolve_group_dn(grp)
if not dn:
return Response(
{"detail": "Группа не найдена в LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
try:
desired_dns = self._members_payload_to_dns(request.data)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
svc = DirectoryService()
try:
prev_dns = svc.group_list_members(dn)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
try:
svc.group_replace_members(dn, desired_dns)
except Exception as e:
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
users = self._dns_to_users(desired_dns)
ok_user_ids = [u.id for u in users]
try:
with transaction.atomic():
grp.user_set.set(users)
except Exception as e:
try:
svc.group_replace_members(dn, prev_dns)
except Exception:
pass
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{
"ok": True,
"ldap_total": len(desired_dns),
"db_total": len(users),
"ok_dns": desired_dns,
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)
else:
member_ids = request.data.get("member_ids") or []
if not isinstance(member_ids, list):
return Response(
{"detail": "member_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
users = Employee.objects.filter(id__in=member_ids)
try:
with transaction.atomic():
grp.user_set.set(users)
except Exception as e:
return Response(
{"detail": f"DB error: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
ok_user_ids = [u.id for u in users]
return Response(
{
"ok": True,
"ldap_total": 0,
"db_total": len(users),
"ok_dns": [],
"ok_user_ids": ok_user_ids,
},
status=status.HTTP_200_OK,
)

View File

@ -0,0 +1,146 @@
"""PositionViewSet — CRUD должностей с LDAP-синхронизацией групп."""
from __future__ import annotations
from django.contrib.auth.models import Group, Permission
from employees.ldap.directory_service import DirectoryService
from employees.models import Position
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..permissions import AdminOrActionOrModelPerms
from ..serializers import PositionSerializer
from ._helpers import HistoryActionMixin, _is_ldap_enabled, _ldap_try
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):
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:
raise Exception(err.data["detail"])
def perform_update(self, serializer):
pos = serializer.save()
_ldap_try(lambda: DirectoryService().reconcile_position(pos))
def perform_destroy(self, instance):
_ldap_try(lambda: DirectoryService().delete_position_group(instance))
return super().perform_destroy(instance)

View File

@ -0,0 +1,409 @@
"""DepartmentRoleViewSet — CRUD ролей отделов с LDAP-синхронизацией."""
from __future__ import annotations
from django.shortcuts import get_object_or_404
from employees.ldap.directory_service import DirectoryService
from employees.ldap.errors import (
DirectoryDbError,
DirectoryLdapError,
)
from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
DeptPerm,
EmployeeDepartment,
RoleAssignment,
)
from employees.utils import _ensure_department_permissions
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..permissions import AdminOrDeptAllowed
from ..serializers import DepartmentRoleSerializer
from ._helpers import Employee, _is_ldap_enabled
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
"""
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"}:
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 → затем запись в БД."""
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:
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 = 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:
role = DepartmentRole.objects.create(
department=dept,
name=name,
)
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 → затем БД."""
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:
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:
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 → затем запись из БД."""
instance = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
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:
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):
"""Полностью заменяет набор прав у роли."""
role = self.get_object()
ids = request.data.get("permission_ids") or []
codes = request.data.get("permission_codes") or []
if isinstance(ids, list) and ids:
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)."""
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):
"""Назначает роль сотруднику."""
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:
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:
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):
"""Отзывает роль у сотрудника."""
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:
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:
RoleAssignment.objects.filter(employee=employee, role=role).update(
is_active=False
)
return Response(status=204)

View File

@ -0,0 +1,135 @@
"""SkillViewSet — CRUD навыков сотрудников."""
from __future__ import annotations
from django.db import transaction
from rest_framework import filters, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..permissions import AdminOrActionOrModelPerms
from ..serializers import SkillSerializer
from ._helpers import Employee
from employees.models import Skill
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
- POST /skills/merge
"""
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,
)
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, опционально переносит всех сотрудников."""
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:
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,
)