375 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""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=<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",
"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],
)