"""EmployeeActionViewSet — кадровые события (приём, увольнение и т.д.).""" from __future__ import annotations import logging from django.db import DatabaseError, connection, transaction from django.utils import timezone from django.utils.dateparse import parse_date, parse_datetime from ldap3.core.exceptions import LDAPException from rest_framework import filters, viewsets from rest_framework.permissions import IsAuthenticated from employees.constants import ( ACTION_DISMISSED, ACTIVATING_MARKER_ACTIONS, PERMANENT_ACTIONS, ) from employees.models import ( Department, Employee, EmployeeAction, EmployeeDepartment, RoleAssignment, ) from employees.services.personnel_state import resolve_employee_personnel_state from ...permissions import AdminOrActionOrModelPerms from ..serializers import EmployeeActionSerializer from ._helpers import HistoryActionMixin logger = logging.getLogger(__name__) def _parse_action_boundary(value): if not value: return None return parse_datetime(value) or parse_date(value) 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= ?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", "date_to", "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") date_from = _parse_action_boundary(df) if date_from: qs = qs.filter(date__gte=date_from) date_to = _parse_action_boundary(dt) if date_to: qs = qs.filter(date__lte=date_to) 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): """Синхронизирует эффекты аккаунта по текущему кадровому состоянию. Работает только с БД. LDAP синхронизация через сигналы: - sync_employee_to_ldap_on_save: активация/деактивация в LDAP - sync_department_member_to_ldap: удаление из отделов в LDAP """ if not self._action_affects_account(action_obj.action): return if action_obj.date and action_obj.date.date() > timezone.localdate(): logger.info( "Future employee action effects skipped", extra={ "event": "employee.action.effects_skipped", "employee_action_id": action_obj.id, "effective_at": action_obj.date.date(), "reason": "future_effective_date", }, ) return self._sync_employee_account_state(action_obj.employee) @staticmethod def _action_affects_account(action: str) -> bool: return action in PERMANENT_ACTIONS or action in ACTIVATING_MARKER_ACTIONS def _sync_employee_account_state(self, emp: Employee): today = timezone.localdate() current_state = resolve_employee_personnel_state(emp, today) should_be_active = current_state.status != ACTION_DISMISSED emp.refresh_from_db(fields=["is_active"]) if not should_be_active: dismissal_date = current_state.date_from or today # Снимаем с должности руководителя (до деактивации!) headed_depts = list(Department.objects.filter(head=emp)) if headed_depts: for dept in headed_depts: logger.info( "Department head cleared due to dismissal", extra={ "event": "employee.department_head.cleared", "employee_id": emp.id, "department_id": dept.id, "reason": "dismissal", }, ) Department.objects.filter(head=emp).update( head=None, head_appointed_at=None ) # Очищаем managedBy в LDAP (best-effort) self._clear_ldap_managed_by(headed_depts) # Деактивируем сотрудника (сигнал синхронизирует в LDAP) if emp.is_active: emp.is_active = False emp._ldap_changes = {"is_active": False} emp.save(update_fields=["is_active"]) # Деактивируем связи с отделами (сигналы синхронизируют в LDAP) # ВАЖНО: используем save() для каждой записи, чтобы сработали # сигналы for emp_dept in EmployeeDepartment.objects.filter( employee=emp, is_active=True ): emp_dept.is_active = False emp_dept.date_to = dismissal_date emp_dept.save(update_fields=["is_active", "date_to"]) for assignment in RoleAssignment.objects.filter( employee=emp, is_active=True ): assignment.is_active = False assignment.save(update_fields=["is_active"]) else: # Активность учетной записи определяется текущим постоянным # кадровым состоянием; временные события сами по себе не меняют # LDAP/DB-активацию. if not emp.is_active: emp.is_active = True emp._ldap_changes = {"is_active": True} emp.save(update_fields=["is_active"]) # Гарантируем, что DN в правильном OU (Users или Dismissed) # Покрывает: восстановление, увольнение без отделов, множественные # отделы self._ensure_ldap_dn_location(emp) def _sync_employee_account_state_by_id(self, employee_id: int | None): if not employee_id: return emp = Employee.objects.filter(id=employee_id).first() if emp: self._sync_employee_account_state(emp) @staticmethod def _ensure_ldap_dn_location(emp): """Гарантирует, что LDAP DN сотрудника в правильном OU. is_active=False → OU=Dismissed is_active=True → OU=Users """ from django.conf import settings if not getattr(settings, "LDAP_ENABLED", False): return try: from employees.ldap.utils.ldap_utils import get_base_dn_for_employee from employees.models import LdapSyncState sync_state = LdapSyncState.objects.filter( model="employee", object_pk=str(emp.pk) ).first() if not sync_state or not sync_state.ldap_dn: return current_dn = sync_state.ldap_dn target_base = get_base_dn_for_employee(emp) # Проверяем, уже ли в правильном OU parts = current_dn.split(",", 1) if len(parts) == 2 and parts[1].lower() == target_base.lower(): return 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, target_base) rdn = parts[0] ok = conn.modify_dn(current_dn, rdn, new_superior=target_base) if ok: new_dn = f"{rdn},{target_base}" sync_state.ldap_dn = new_dn sync_state.save(update_fields=["ldap_dn"]) logger.info( "Employee LDAP entry moved", extra={ "event": "ldap.employee.moved", "employee_id": emp.id, "target_container_changed": True, }, ) else: logger.warning( "Employee LDAP move failed", extra={ "event": "ldap.employee.move_failed", "employee_id": emp.id, "ldap_result_code": (conn.result or {}).get("result"), "ldap_result_description": (conn.result or {}).get( "description" ), }, ) except (DatabaseError, LDAPException, OSError, ValueError) as exc: logger.warning( "Employee LDAP location reconciliation failed", extra={ "event": "ldap.employee.location_reconciliation_failed", "employee_id": emp.id, "error_type": type(exc).__name__, }, exc_info=True, ) @staticmethod def _clear_ldap_managed_by(departments): """Очищает LDAP managedBy для переданных отделов (best-effort).""" from django.conf import settings if not getattr(settings, "LDAP_ENABLED", False): return try: from employees.ldap.orm_models import LdapOrganizationalUnit from employees.models import LdapSyncState for dept in departments: sync = LdapSyncState.objects.filter( model="department", object_pk=str(dept.pk) ).first() if not sync or not sync.ldap_dn: continue try: ou = LdapOrganizationalUnit.objects.get(dn=sync.ldap_dn) if ou.managed_by: ou.managed_by = "" ou.save() logger.info( "LDAP department manager cleared", extra={ "event": "ldap.department.manager_cleared", "department_id": dept.pk, }, ) except LdapOrganizationalUnit.DoesNotExist: pass except (LDAPException, OSError, ValueError) as exc: logger.warning( "LDAP department manager clearing failed", extra={ "event": "ldap.department.manager_clear_failed", "department_id": dept.pk, "error_type": type(exc).__name__, }, exc_info=True, ) except (DatabaseError, LDAPException, OSError, ValueError) as exc: logger.warning( "LDAP department manager cleanup failed", extra={ "event": "ldap.department.manager_cleanup_failed", "department_count": len(departments), "error_type": type(exc).__name__, }, exc_info=True, ) @transaction.atomic def perform_create(self, serializer): obj = serializer.save() self._apply_effects(obj) @transaction.atomic def perform_update(self, serializer): old_employee_id = serializer.instance.employee_id old_action = serializer.instance.action obj = serializer.save() affected_employee_ids = set() if self._action_affects_account(old_action): affected_employee_ids.add(old_employee_id) if self._action_affects_account(obj.action): affected_employee_ids.add(obj.employee_id) for employee_id in affected_employee_ids: self._sync_employee_account_state_by_id(employee_id) @transaction.atomic def perform_destroy(self, instance): employee_id = instance.employee_id action = instance.action self._clear_legacy_state_references(instance.id) super().perform_destroy(instance) if self._action_affects_account(action): self._sync_employee_account_state_by_id(employee_id) @staticmethod def _clear_legacy_state_references(action_id: int): """Remove stale references from abandoned normalized-state tables.""" legacy_tables = ( "employees_employeebasestatusevent", "employees_employeestatusinterval", ) existing_tables = set(connection.introspection.table_names()) tables_to_clean = [table for table in legacy_tables if table in existing_tables] if not tables_to_clean: return with connection.cursor() as cursor: for table in tables_to_clean: cursor.execute( f'DELETE FROM "{table}" WHERE source_action_id = %s', [action_id], )