Corp/backend/api/v1/employees/views/departments.py

351 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""DepartmentViewSet — CRUD отделов + действия (set_head, add_member и т.д.)."""
from __future__ import annotations
import logging
from typing import Any, Dict
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 rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from employees.models import (
Department,
DeptPerm,
EmployeeDepartment,
RoleAssignment,
)
from employees.utils import (
_validate_head_active,
)
from ...permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
)
from ..serializers import (
DepartmentSerializer,
SetHeadInput,
)
from .department_mixins import (
DepartmentMembershipActionsMixin,
DepartmentResourceActionsMixin,
)
logger = logging.getLogger(__name__)
class DepartmentViewSet(
DepartmentMembershipActionsMixin,
DepartmentResourceActionsMixin,
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",
"calendar",
"discussion_chat",
"user_perms",
"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_role_assignments = RoleAssignment.objects.filter(
role__department_id=OuterRef("pk"),
is_active=True,
).exclude(
employee__departments_links__department_id=OuterRef("pk"),
employee__departments_links__is_active=True,
)
active_count_subq = (
active_links.values("department_id")
.annotate(c=Count("employee_id", distinct=True))
.values("c")[:1]
)
role_only_count_subq = (
active_role_assignments.values("role__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),
),
role_only_count=Coalesce(
Subquery(role_only_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 _extract_department_ldap_changes(
self, instance: Department, data: Dict[str, Any]
) -> Dict[str, Any]:
"""Собирает diff значимых LDAP-полей до сохранения."""
changes: Dict[str, Any] = {}
for field in ("name", "description"):
if field not in data:
continue
new_value = data.get(field)
if new_value != getattr(instance, field):
changes[field] = new_value
return changes
def _mark_department_sync_attrs(
self,
instance: Department,
*,
changes: Dict[str, Any] | None = None,
sync_head: bool = False,
) -> None:
"""Передаёт post_save сигналу точный diff отдела."""
if changes:
existing = getattr(instance, "_ldap_changes", {}) or {}
if not isinstance(existing, dict):
existing = {}
existing.update(changes)
instance._ldap_changes = existing
if sync_head:
instance._ldap_sync_head = True
# --- частичное изменение ---
def _perform_set_head(
self, instance: Department, desired_head_id: int | None, request
) -> Response | Department:
"""Общая логика назначения руководителя.
Используется для set_head action и partial_update.
Возвращает Response при ошибке или обновлённый Department при успехе.
"""
# Проверка прав
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):
return instance
# Валидация кандидата
new_head = None
if desired_head_id is not None:
employee_model = Department._meta.get_field("head").remote_field.model
new_head = get_object_or_404(
employee_model.objects.visible_in_ui(),
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)
instance.head = new_head
if new_head:
instance.head_appointed_at = timezone.now()
else:
instance.head_appointed_at = None
self._mark_department_sync_attrs(instance, sync_head=True)
instance.save(update_fields=["head", "head_appointed_at"])
return instance
def perform_update(self, serializer):
"""Обновление отдела с фиксацией LDAP diff до save()."""
instance = serializer.instance
validated_data = serializer.validated_data
self._mark_department_sync_attrs(
instance,
changes=self._extract_department_ldap_changes(instance, validated_data),
sync_head=(
"head" in validated_data and validated_data.get("head") != instance.head
),
)
serializer.save()
def partial_update(self, request, *args, **kwargs) -> Response:
"""Частичный апдейт отдела."""
instance = self.get_object()
data: Dict[str, Any] = request.data
# --- HEAD ---
if any(k in data for k in ("head", "head_id")):
raw_desired = data.get("head_id", data.get("head", None))
try:
if 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
)
# Используем общий метод
result = self._perform_set_head(instance, desired_head_id, request)
if isinstance(result, Response):
return result
instance = result
# --- NAME / DESCRIPTION ---
changes = self._extract_department_ldap_changes(instance, data)
if changes:
for k, v in changes.items():
setattr(instance, k, v)
self._mark_department_sync_attrs(instance, changes=changes)
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"])
def set_head(self, request, pk: str | None = None) -> Response:
"""Назначение/снятие руководителя отдела."""
dept = self.get_object()
payload = SetHeadInput(data=request.data)
payload.is_valid(raise_exception=True)
head_id = payload.validated_data.get("head_id")
# Используем общий метод
result = self._perform_set_head(dept, head_id, request)
if isinstance(result, Response):
return result
return Response(self.get_serializer(result).data, status=status.HTTP_200_OK)