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:
parent
6164ee038a
commit
8efcabf530
@ -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
|
||||
|
||||
20
backend/api/v1/employees/permissions.py
Normal file
20
backend/api/v1/employees/permissions.py
Normal 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
28
backend/api/v1/employees/views/__init__.py
Normal file
28
backend/api/v1/employees/views/__init__.py
Normal 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",
|
||||
]
|
||||
115
backend/api/v1/employees/views/_helpers.py
Normal file
115
backend/api/v1/employees/views/_helpers.py
Normal 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})
|
||||
264
backend/api/v1/employees/views/actions.py
Normal file
264
backend/api/v1/employees/views/actions.py
Normal 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)
|
||||
321
backend/api/v1/employees/views/auth.py
Normal file
321
backend/api/v1/employees/views/auth.py
Normal 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,
|
||||
)
|
||||
751
backend/api/v1/employees/views/departments.py
Normal file
751
backend/api/v1/employees/views/departments.py
Normal 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)
|
||||
930
backend/api/v1/employees/views/employees.py
Normal file
930
backend/api/v1/employees/views/employees.py
Normal 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)
|
||||
689
backend/api/v1/employees/views/groups.py
Normal file
689
backend/api/v1/employees/views/groups.py
Normal 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,
|
||||
)
|
||||
146
backend/api/v1/employees/views/positions.py
Normal file
146
backend/api/v1/employees/views/positions.py
Normal 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)
|
||||
409
backend/api/v1/employees/views/roles.py
Normal file
409
backend/api/v1/employees/views/roles.py
Normal 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)
|
||||
135
backend/api/v1/employees/views/skills.py
Normal file
135
backend/api/v1/employees/views/skills.py
Normal 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,
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user