338 lines
12 KiB
Python
338 lines
12 KiB
Python
# employees/signals/common.py
|
||
"""Общие Django signals для приложения employees.
|
||
|
||
Обработчики:
|
||
- Создание кадровых событий (EmployeeAction)
|
||
- Автоматизация из заявок (requests_app)
|
||
"""
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
|
||
from django.db.models.signals import post_save, pre_save
|
||
from django.dispatch import receiver
|
||
from django.utils import timezone
|
||
|
||
from employees.constants import (
|
||
ACTION_HIRED,
|
||
ACTION_ON_DAY_OFF,
|
||
ACTION_ON_LEAVE,
|
||
ACTION_ON_MATERNITY,
|
||
ACTION_ON_SICK_LEAVE,
|
||
)
|
||
from employees.models import Employee, EmployeeAction
|
||
from employees.services.request_actions import (
|
||
build_request_action_comment,
|
||
create_request_action,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ============================================================
|
||
# Автоматизация создания кадровых событий из заявок
|
||
# ============================================================
|
||
|
||
# Немедленные события (создаются сразу при одобрении)
|
||
IMMEDIATE_ACTION_MAPPING = {
|
||
"transfer": "transferred", # Перевод → Переведен
|
||
"dismissal": "dismissed", # Увольнение → Уволен
|
||
}
|
||
|
||
# Интервальные события (создаются сразу при одобрении с датами заявки)
|
||
SCHEDULED_ACTION_MAPPING = {
|
||
"short_day_off": ACTION_ON_DAY_OFF, # Короткий отгул → частичный отгул
|
||
"vacation": ACTION_ON_LEAVE, # Отпуск → В отпуске
|
||
"sick_leave": ACTION_ON_SICK_LEAVE, # Больничный → На больничном
|
||
"day_off": ACTION_ON_DAY_OFF, # Отгул → В отгуле
|
||
"maternity": ACTION_ON_MATERNITY, # Декрет → В декрете
|
||
}
|
||
|
||
|
||
@receiver(post_save, sender=Employee)
|
||
def create_hired_action(sender, instance: Employee, created, **kwargs):
|
||
"""
|
||
При первом сохранении нового сотрудника создаём событие «Принят».
|
||
"""
|
||
if created:
|
||
EmployeeAction.objects.create(
|
||
employee=instance,
|
||
action=ACTION_HIRED,
|
||
date=instance.created_at or timezone.now(),
|
||
comment="Автоматически: принят при регистрации",
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Обработчики заявок (requests_app)
|
||
# ============================================================
|
||
|
||
|
||
@receiver(pre_save, sender="requests_app.Request")
|
||
def track_request_status_change(sender, instance, **kwargs):
|
||
"""Сохраняем старый статус перед обновлением для отслеживания изменений."""
|
||
if instance.pk:
|
||
try:
|
||
Request = sender
|
||
old_instance = Request.objects.get(pk=instance.pk)
|
||
instance._old_status = old_instance.status
|
||
except sender.DoesNotExist:
|
||
instance._old_status = None
|
||
|
||
|
||
@receiver(post_save, sender="requests_app.Request")
|
||
def create_action_on_request_approval(sender, instance, created, **kwargs):
|
||
"""
|
||
Автоматически создает EmployeeAction при одобрении заявок.
|
||
|
||
Две стратегии:
|
||
1. НЕМЕДЛЕННО (transfer, dismissal) - сразу при одобрении
|
||
2. ИНТЕРВАЛЬНО (отпуск, больничный, отгулы, декрет) - сразу с date_to
|
||
"""
|
||
# Пропускаем если это новая заявка
|
||
if created:
|
||
return
|
||
|
||
# Получаем enum из requests_app
|
||
try:
|
||
from requests_app.enums import RequestStatus
|
||
except ImportError as error:
|
||
logger.exception(
|
||
"Employee action request status import failed",
|
||
extra={
|
||
"event": "employee.action.request_status_import_failed",
|
||
"error_type": type(error).__name__,
|
||
},
|
||
)
|
||
return
|
||
|
||
# Проверяем изменение статуса
|
||
old_status = getattr(instance, "_old_status", None)
|
||
if old_status == RequestStatus.APPROVED:
|
||
return # Уже была одобрена
|
||
|
||
if instance.status != RequestStatus.APPROVED:
|
||
return # Не одобрение
|
||
|
||
# === СТРАТЕГИЯ 1: Немедленное создание ===
|
||
if instance.type in IMMEDIATE_ACTION_MAPPING:
|
||
_create_immediate_action(instance)
|
||
|
||
# === СТРАТЕГИЯ 2: Интервальное событие сразу после одобрения ===
|
||
elif instance.type in SCHEDULED_ACTION_MAPPING:
|
||
_create_interval_action(instance)
|
||
|
||
else:
|
||
logger.debug(
|
||
"Approved request does not map to an employee action",
|
||
extra={
|
||
"event": "employee.action.creation_skipped",
|
||
"business_request_id": instance.id,
|
||
"request_type": instance.type,
|
||
"reason": "request_type_not_mapped",
|
||
},
|
||
)
|
||
|
||
|
||
def _create_immediate_action(request):
|
||
"""Создать событие немедленно (для увольнения, перевода)."""
|
||
action_type = IMMEDIATE_ACTION_MAPPING.get(request.type)
|
||
if not action_type:
|
||
return
|
||
|
||
try:
|
||
action, created = create_request_action(
|
||
request=request,
|
||
action_type=action_type,
|
||
action_date=request.date_from or request.decided_at or timezone.now(),
|
||
comment=build_request_action_comment(request),
|
||
extra={
|
||
"approved_by": (request.approver.id if request.approver else None),
|
||
"immediate": True,
|
||
},
|
||
)
|
||
if not created:
|
||
logger.warning(
|
||
"Immediate employee action creation skipped because it already exists",
|
||
extra={
|
||
"event": "employee.action.immediate_duplicate_skipped",
|
||
"business_request_id": request.id,
|
||
"action_type": action_type,
|
||
},
|
||
)
|
||
return
|
||
|
||
# Применяем эффекты (деактивация, LDAP sync)
|
||
_apply_action_effects(action)
|
||
|
||
logger.info(
|
||
"Immediate employee action created",
|
||
extra={
|
||
"event": "employee.action.immediate_created",
|
||
"employee_action_id": action.id,
|
||
"business_request_id": request.id,
|
||
"action_type": action_type,
|
||
},
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.exception(
|
||
"Immediate employee action creation failed",
|
||
extra={
|
||
"event": "employee.action.immediate_creation_failed",
|
||
"business_request_id": request.id,
|
||
"action_type": action_type,
|
||
"error_type": type(e).__name__,
|
||
},
|
||
)
|
||
|
||
|
||
def _schedule_delayed_action(request):
|
||
"""Запланировать создание события через Celery."""
|
||
try:
|
||
from requests_app.tasks import create_scheduled_action
|
||
except ImportError as error:
|
||
logger.exception(
|
||
"Scheduled employee action task import failed",
|
||
extra={
|
||
"event": "employee.action.task_import_failed",
|
||
"business_request_id": request.id,
|
||
"error_type": type(error).__name__,
|
||
},
|
||
)
|
||
return
|
||
|
||
if not request.date_from:
|
||
logger.warning(
|
||
"Scheduled employee action creation skipped because date is missing",
|
||
extra={
|
||
"event": "employee.action.scheduling_skipped",
|
||
"business_request_id": request.id,
|
||
"request_type": request.type,
|
||
"reason": "date_from_missing",
|
||
},
|
||
)
|
||
return
|
||
|
||
# Если date_from уже прошла или сегодня - создаем сразу
|
||
today = timezone.now().date()
|
||
if request.date_from <= today:
|
||
logger.info(
|
||
"Scheduled employee action is due and will run immediately",
|
||
extra={
|
||
"event": "employee.action.immediate_execution_selected",
|
||
"business_request_id": request.id,
|
||
"effective_at": request.date_from,
|
||
},
|
||
)
|
||
# Вызываем task синхронно
|
||
create_scheduled_action.apply(args=[request.id])
|
||
return
|
||
|
||
# Иначе планируем на date_from в 00:00
|
||
eta = timezone.make_aware(datetime.combine(request.date_from, datetime.min.time()))
|
||
|
||
try:
|
||
task = create_scheduled_action.apply_async(args=[request.id], eta=eta)
|
||
logger.info(
|
||
"Employee action creation scheduled",
|
||
extra={
|
||
"event": "employee.action.creation_scheduled",
|
||
"business_request_id": request.id,
|
||
"task_id": task.id,
|
||
"scheduled_at": eta,
|
||
},
|
||
)
|
||
except Exception as e:
|
||
logger.exception(
|
||
"Employee action scheduling failed",
|
||
extra={
|
||
"event": "employee.action.scheduling_failed",
|
||
"business_request_id": request.id,
|
||
"scheduled_at": eta,
|
||
"error_type": type(e).__name__,
|
||
},
|
||
)
|
||
|
||
|
||
def _create_interval_action(request):
|
||
"""Создать временное кадровое событие сразу после одобрения заявки."""
|
||
action_type = SCHEDULED_ACTION_MAPPING.get(request.type)
|
||
if not action_type:
|
||
return
|
||
|
||
try:
|
||
is_partial_day_off = request.type == "short_day_off"
|
||
extra = {
|
||
"approved_by": (request.approver.id if request.approver else None),
|
||
"interval": True,
|
||
}
|
||
if is_partial_day_off:
|
||
extra.update(
|
||
{
|
||
"partial_day": True,
|
||
"starts_at": request.starts_at.isoformat(),
|
||
"ends_at": request.ends_at.isoformat(),
|
||
"duration_minutes": request.duration_minutes,
|
||
}
|
||
)
|
||
|
||
action, created = create_request_action(
|
||
request=request,
|
||
action_type=action_type,
|
||
action_date=request.starts_at if is_partial_day_off else request.date_from,
|
||
date_to=request.date_to,
|
||
comment=build_request_action_comment(request),
|
||
extra=extra,
|
||
)
|
||
if not created:
|
||
logger.warning(
|
||
"Interval employee action creation skipped because it already exists",
|
||
extra={
|
||
"event": "employee.action.interval_duplicate_skipped",
|
||
"business_request_id": request.id,
|
||
"action_type": action_type,
|
||
},
|
||
)
|
||
return
|
||
|
||
logger.info(
|
||
"Interval employee action created",
|
||
extra={
|
||
"event": "employee.action.interval_created",
|
||
"employee_action_id": action.id,
|
||
"business_request_id": request.id,
|
||
"action_type": action_type,
|
||
},
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.exception(
|
||
"Interval employee action creation failed",
|
||
extra={
|
||
"event": "employee.action.interval_creation_failed",
|
||
"business_request_id": request.id,
|
||
"action_type": action_type,
|
||
"error_type": type(e).__name__,
|
||
},
|
||
)
|
||
|
||
|
||
def _apply_action_effects(action):
|
||
"""Применяет эффекты кадрового события (как в EmployeeActionViewSet)."""
|
||
try:
|
||
from api.v1.employees.views.actions import EmployeeActionViewSet
|
||
|
||
viewset = EmployeeActionViewSet()
|
||
viewset._apply_effects(action)
|
||
except Exception as e:
|
||
logger.exception(
|
||
"Employee action effects application failed",
|
||
extra={
|
||
"event": "employee.action.effects_failed",
|
||
"employee_action_id": action.id,
|
||
"employee_id": action.employee_id,
|
||
"error_type": type(e).__name__,
|
||
},
|
||
)
|