338 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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__,
},
)