diff --git a/backend/employees/signals.py b/backend/employees/signals.py index c727dcd1..4e454376 100644 --- a/backend/employees/signals.py +++ b/backend/employees/signals.py @@ -1,5 +1,9 @@ # users/signals.py + +import logging import os +from datetime import datetime + from django.contrib.contenttypes.models import ContentType from django.core.files.base import ContentFile from django.db.models import Q @@ -10,6 +14,25 @@ from .constants import ACTION_HIRED from .models import Employee, EmployeeAction, Department +logger = logging.getLogger(__name__) + + +# ============================================================ +# Автоматизация создания кадровых событий из заявок +# ============================================================ + +# Немедленные события (создаются сразу при одобрении) +IMMEDIATE_ACTION_MAPPING = { + "transfer": "transferred", # Перевод → Переведен + "dismissal": "dismissed", # Увольнение → Уволен +} + +# Отложенные события (создаются по Celery в date_from) +SCHEDULED_ACTION_MAPPING = { + "vacation": "on_leave", # Отпуск → В отпуске + "sick_leave": "on_sick_leave", # Больничный → На больничном +} + @receiver(post_save, sender=Employee) def create_hired_action(sender, instance: Employee, created, **kwargs): @@ -60,3 +83,174 @@ def create_main_department_chat(sender, instance, created, **kwargs): except ImportError: # communications app не установлен - пропускаем pass + + +# ============================================================ +# Обработчики заявок (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. ОТЛОЖЕННО (vacation, sick_leave) - через Celery в date_from + """ + # Пропускаем если это новая заявка + if created: + return + + # Получаем enum из requests_app + try: + from requests_app.enums import RequestStatus + except ImportError: + logger.error("Cannot import RequestStatus from requests_app") + 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: Отложенное создание через Celery === + elif instance.type in SCHEDULED_ACTION_MAPPING: + _schedule_delayed_action(instance) + + else: + logger.debug( + f"Request #{instance.id} approved but type '{instance.type}' " + f"doesn't create EmployeeAction" + ) + + +def _create_immediate_action(request): + """Создать событие немедленно (для увольнения, перевода).""" + action_type = IMMEDIATE_ACTION_MAPPING.get(request.type) + if not action_type: + return + + # Проверяем дубли + if EmployeeAction.objects.filter( + extra__request_id=request.id, + action=action_type + ).exists(): + logger.warning( + f"EmployeeAction already exists for Request #{request.id}" + ) + return + + # Создаем событие + action_date = request.date_from or request.decided_at or timezone.now() + action_comment = f"Заявление #{request.id}" + if request.comment: + action_comment += f": {request.comment[:200]}" + + try: + action = EmployeeAction.objects.create( + employee=request.employee, + action=action_type, + date=action_date, + comment=action_comment, + extra={ + 'request_id': request.id, + 'approved_by': ( + request.approver.id if request.approver else None + ), + 'immediate': True + } + ) + + # Применяем эффекты (деактивация, LDAP sync) + _apply_action_effects(action) + + logger.info( + f"Created immediate EmployeeAction #{action.id} ({action_type}) " + f"from Request #{request.id}" + ) + + except Exception as e: + logger.error( + f"Failed to create EmployeeAction for Request #{request.id}: {e}", + exc_info=True + ) + + +def _schedule_delayed_action(request): + """Запланировать создание события через Celery.""" + try: + from requests_app.tasks import create_scheduled_action + except ImportError: + logger.error("Cannot import create_scheduled_action task") + return + + if not request.date_from: + logger.warning( + f"Request #{request.id} type '{request.type}' needs date_from " + f"for scheduling" + ) + return + + # Если date_from уже прошла или сегодня - создаем сразу + today = timezone.now().date() + if request.date_from <= today: + logger.info( + f"Request #{request.id} date_from is today or past, " + f"creating action immediately" + ) + # Вызываем 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( + f"Scheduled EmployeeAction creation for Request #{request.id} " + f"at {eta} (task_id={task.id})" + ) + except Exception as e: + logger.error( + f"Failed to schedule action for Request #{request.id}: {e}", + exc_info=True + ) + + +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.error( + f"Failed to apply effects for EmployeeAction #{action.id}: {e}", + exc_info=True + ) diff --git a/backend/eusrr_backend/celery.py b/backend/eusrr_backend/celery.py index a47f8c4c..36fc04d3 100644 --- a/backend/eusrr_backend/celery.py +++ b/backend/eusrr_backend/celery.py @@ -3,6 +3,7 @@ """ import os from celery import Celery +from celery.schedules import crontab # Устанавливаем настройки Django по умолчанию os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings') @@ -17,6 +18,14 @@ app.config_from_object('django.conf:settings', namespace='CELERY') # Автоматически находит tasks.py в каждом приложении app.autodiscover_tasks() +# Periodic tasks (Celery Beat) +app.conf.beat_schedule = { + 'cleanup-missed-returns': { + 'task': 'requests_app.tasks.cleanup_missed_returns', + 'schedule': crontab(hour=0, minute=5), # Каждый день в 00:05 + }, +} + @app.task(bind=True, ignore_result=True) def debug_task(self): diff --git a/backend/requests_app/__init__.py b/backend/requests_app/__init__.py index e69de29b..f6f84776 100644 --- a/backend/requests_app/__init__.py +++ b/backend/requests_app/__init__.py @@ -0,0 +1 @@ +default_app_config = 'requests_app.apps.RequestsAppConfig' diff --git a/backend/requests_app/apps.py b/backend/requests_app/apps.py index cfc86788..597afc82 100644 --- a/backend/requests_app/apps.py +++ b/backend/requests_app/apps.py @@ -8,4 +8,5 @@ class RequestsAppConfig(AppConfig): def ready(self): # Подключаем модуль уведомлений (signals регистрируются автоматически) import requests_app.notifications.signals # noqa + # import requests_app.signals # MOVED: Автоматическое создание EmployeeAction теперь в employees/signals.py import requests_app.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/requests_app/tasks.py b/backend/requests_app/tasks.py new file mode 100644 index 00000000..770d18b4 --- /dev/null +++ b/backend/requests_app/tasks.py @@ -0,0 +1,270 @@ +""" +Celery tasks для автоматизации кадровых событий из заявок. +""" +import logging +from datetime import date, timedelta + +from celery import shared_task +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3) +def create_scheduled_action(self, request_id): + """ + Создать кадровое событие для заявки по расписанию. + + Вызывается в date_from для vacation/sick_leave. + + Args: + request_id: ID заявки + """ + from .models import Request + from .enums import RequestStatus + from employees.models import EmployeeAction + + try: + request = Request.objects.get(id=request_id) + + # Проверяем, что заявка все еще одобрена + if request.status != RequestStatus.APPROVED: + logger.info( + f"Request #{request_id} is no longer approved " + f"(status={request.status}), skipping action creation" + ) + return + + # Определяем тип события + action_mapping = { + 'vacation': 'on_leave', + 'sick_leave': 'on_sick_leave', + } + + action_type = action_mapping.get(request.type) + if not action_type: + logger.warning( + f"Request #{request_id} has type '{request.type}' " + f"which doesn't map to scheduled action" + ) + return + + # Проверяем, что событие еще не создано + if EmployeeAction.objects.filter( + extra__request_id=request_id, + action=action_type + ).exists(): + logger.info( + f"Action already exists for Request #{request_id}, skipping" + ) + return + + # Создаем событие + action_comment = f"Заявление #{request.id}" + if request.comment: + action_comment += f": {request.comment[:200]}" + + action = EmployeeAction.objects.create( + employee=request.employee, + action=action_type, + date=timezone.now(), + comment=action_comment, + extra={ + 'request_id': request.id, + 'approved_by': ( + request.approver.id if request.approver else None + ), + 'scheduled': True + } + ) + + logger.info( + f"Created scheduled EmployeeAction #{action.id} ({action_type}) " + f"for Request #{request_id}" + ) + + # Планируем автовозврат на date_to + 1 день + if request.date_to: + schedule_auto_return.apply_async( + args=[request_id], + eta=timezone.make_aware( + timezone.datetime.combine( + request.date_to + timedelta(days=1), + timezone.datetime.min.time() + ) + ) + ) + + return f"Created action {action.id}" + + except Request.DoesNotExist: + logger.error(f"Request #{request_id} not found") + raise + except Exception as e: + logger.error( + f"Failed to create scheduled action for Request #{request_id}: {e}", + exc_info=True + ) + # Retry через 5 минут + raise self.retry(exc=e, countdown=300) + + +@shared_task(bind=True, max_retries=3) +def schedule_auto_return(self, request_id): + """ + Автоматически создать событие возврата из отпуска/больничного. + + Вызывается в date_to + 1 день. + + Args: + request_id: ID заявки + """ + from .models import Request + from .enums import RequestStatus + from employees.models import EmployeeAction + + try: + request = Request.objects.get(id=request_id) + + # Проверяем, что заявка все еще одобрена + if request.status != RequestStatus.APPROVED: + logger.info( + f"Request #{request_id} is no longer approved, " + f"skipping auto-return" + ) + return + + # Определяем тип возврата + return_mapping = { + 'vacation': 'returned_from_leave', + 'sick_leave': 'returned_from_leave', + } + + return_action = return_mapping.get(request.type) + if not return_action: + logger.warning( + f"Request #{request_id} type '{request.type}' " + f"doesn't have return action" + ) + return + + # Проверяем, что событие возврата еще не создано + if EmployeeAction.objects.filter( + extra__request_id=request_id, + action=return_action + ).exists(): + logger.info( + f"Return action already exists for Request #{request_id}" + ) + return + + # Создаем событие возврата + return_comment = ( + f"Автоматически: окончание " + f"{request.get_type_display().lower()} " + f"(заявка #{request.id})" + ) + + action = EmployeeAction.objects.create( + employee=request.employee, + action=return_action, + date=timezone.now(), + comment=return_comment, + extra={ + 'request_id': request.id, + 'auto_return': True + } + ) + + logger.info( + f"Created auto-return EmployeeAction #{action.id} " + f"({return_action}) for Request #{request_id}" + ) + + return f"Created return action {action.id}" + + except Request.DoesNotExist: + logger.error(f"Request #{request_id} not found for auto-return") + raise + except Exception as e: + logger.error( + f"Failed to create auto-return for Request #{request_id}: {e}", + exc_info=True + ) + raise self.retry(exc=e, countdown=300) + + +@shared_task +def cleanup_missed_returns(): + """ + Periodic task: проверяет заявки, для которых date_to прошел, + но событие возврата не создано. + + Запускается ежедневно в 00:05. + """ + from .models import Request + from .enums import RequestStatus + from employees.models import EmployeeAction + + logger.info("Running cleanup_missed_returns task") + + today = date.today() + yesterday = today - timedelta(days=1) + + # Ищем одобренные заявки, которые закончились вчера или раньше + ended_requests = Request.objects.filter( + status=RequestStatus.APPROVED, + type__in=['vacation', 'sick_leave'], + date_to__lt=today, + date_to__gte=yesterday - timedelta(days=7) # За последнюю неделю + ) + + created_count = 0 + + for req in ended_requests: + # Определяем тип возврата + return_action = { + 'vacation': 'returned_from_leave', + 'sick_leave': 'returned_from_leave', + }.get(req.type) + + if not return_action: + continue + + # Проверяем, что события возврата нет + if EmployeeAction.objects.filter( + extra__request_id=req.id, + action=return_action + ).exists(): + continue + + # Создаем пропущенное событие возврата + try: + action = EmployeeAction.objects.create( + employee=req.employee, + action=return_action, + date=req.date_to + timedelta(days=1), + comment=( + f"Автоматически восстановлено: окончание " + f"{req.get_type_display().lower()} (заявка #{req.id})" + ), + extra={ + 'request_id': req.id, + 'auto_return': True, + 'cleanup': True + } + ) + created_count += 1 + logger.info( + f"Cleanup: created return action #{action.id} " + f"for Request #{req.id}" + ) + except Exception as e: + logger.error( + f"Cleanup: failed to create return for Request #{req.id}: {e}" + ) + + logger.info( + f"cleanup_missed_returns completed: {created_count} actions created" + ) + return created_count diff --git a/docs/completed/REQUESTS_CELERY_AUTOMATION.md b/docs/completed/REQUESTS_CELERY_AUTOMATION.md new file mode 100644 index 00000000..37f6a522 --- /dev/null +++ b/docs/completed/REQUESTS_CELERY_AUTOMATION.md @@ -0,0 +1,318 @@ +# Автоматизация кадровых событий через Celery + +**Дата**: 17 марта 2026 г. +**Статус**: ✅ Реализовано + +## Что сделано + +Реализована **полная автоматизация** создания кадровых событий из заявок с использованием **Celery + Redis**. + +## Архитектура + +### 3 стратегии обработки заявок: + +``` +┌─────────────────────────────────────────────┐ +│ 1. НЕМЕДЛЕННЫЕ (dismissal, transfer) │ +│ ↓ approve() → Signal → EmployeeAction │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ 2. ОТЛОЖЕННЫЕ (vacation, sick_leave) │ +│ ↓ approve() → Signal → Celery Task │ +│ ↓ date_from → EmployeeAction │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ 3. АВТОВОЗВРАТ (vacation, sick_leave) │ +│ ↓ date_to + 1 день → Celery Task │ +│ ↓ EmployeeAction (returned_from_leave) │ +└─────────────────────────────────────────────┘ +``` + +--- + +## Файлы + +### 1. `requests_app/tasks.py` - Celery задачи + +**Задачи**: +- `create_scheduled_action()` - создает событие в date_from +- `schedule_auto_return()` - создает событие возврата в date_to + 1 +- `cleanup_missed_returns()` - periodic task для восстановления пропущенных + +### 2. `requests_app/signals.py` - обработчик одобрения + +**Логика**: +- Отслеживает переход в APPROVED +- Немедленные типы → создает сразу +- Отложенные типы → планирует через Celery + +### 3. `eusrr_backend/celery.py` - Celery beat schedule + +**Periodic tasks**: +- `cleanup-missed-returns` - каждый день в 00:05 + +--- + +## Таблица автоматизации + +| Заявка | Событие начала | Когда | Событие возврата | Когда | +|--------|----------------|-------|------------------|-------| +| **dismissal** | `dismissed` | ✅ Сразу при approve | - | - | +| **transfer** | `transferred` | ✅ Сразу при approve | - | - | +| **vacation** | `on_leave` | 🕐 В `date_from` | `returned_from_leave` | 🕐 `date_to` + 1 день | +| **sick_leave** | `on_sick_leave` | 🕐 В `date_from` | `returned_from_leave` | 🕐 `date_to` + 1 день | +| **day_off** | - | ❌ Не создается | - | - | +| **other** | - | ❌ Не создается | - | - | + +--- + +## Примеры работы + +### Пример 1: Увольнение (немедленное) + +```python +# 17 марта - создаем заявку +request = Request.objects.create( + employee=user, + type='dismissal', + date_from='2026-04-01' +) + +# 17 марта - HR одобряет +request.approve(hr_manager) + +# ✅ СРАЗУ создано EmployeeAction(action='dismissed') +# ✅ employee.is_active = False +# ✅ LDAP sync выполнен +``` + +### Пример 2: Отпуск (отложенное) + +```python +# 17 марта - создаем заявку на отпуск 1-14 апреля +request = Request.objects.create( + employee=user, + type='vacation', + date_from='2026-04-01', + date_to='2026-04-14' +) + +# 17 марта - HR одобряет +request.approve(hr_manager) +# ✅ Celery task запланирован на 01.04.2026 00:00 + +# 1 апреля 00:00 - Celery автоматически: +# ✅ Создает EmployeeAction(action='on_leave') +# ✅ Планирует автовозврат на 15.04.2026 00:00 + +# 15 апреля 00:00 - Celery автоматически: +# ✅ Создает EmployeeAction(action='returned_from_leave') +``` + +### Пример 3: Отпуск начинается сегодня + +```python +# 17 марта - создаем заявку с date_from=сегодня +request = Request.objects.create( + employee=user, + type='vacation', + date_from='2026-03-17', # Сегодня! + date_to='2026-03-24' +) + +# 17 марта - HR одобряет +request.approve(hr_manager) +# ✅ Событие создается СРАЗУ (не через Celery) +# ✅ Автовозврат запланирован на 25.03.2026 +``` + +--- + +## Защита от ошибок + +### 1. **Idempotency** (защита от дублей) +```python +# Проверка перед созданием +if EmployeeAction.objects.filter( + extra__request_id=request_id, + action=action_type +).exists(): + return # Уже создано +``` + +### 2. **Retry логика** +```python +@shared_task(bind=True, max_retries=3) +def create_scheduled_action(self, request_id): + try: + # ... создание + except Exception as e: + # Retry через 5 минут + raise self.retry(exc=e, countdown=300) +``` + +### 3. **Cleanup task** (восстановление пропущенных) +```python +# Каждый день в 00:05 проверяет: +# - Заявки, которые закончились, но нет события возврата +# - Создает пропущенные события +``` + +### 4. **Отмена заявки** +```python +# Если заявку отменили до date_from: +# - Celery task выполнится +# - Проверит статус: if status != APPROVED: return +# - Событие НЕ будет создано +``` + +--- + +## Метаданные в extra + +Каждое созданное событие содержит: + +```python +# Немедленное событие +extra = { + 'request_id': 123, + 'approved_by': 5, + 'immediate': True +} + +# Отложенное событие +extra = { + 'request_id': 123, + 'approved_by': 5, + 'scheduled': True +} + +# Автовозврат +extra = { + 'request_id': 123, + 'auto_return': True +} + +# Восстановленное cleanup'ом +extra = { + 'request_id': 123, + 'auto_return': True, + 'cleanup': True +} +``` + +--- + +## Запуск + +### Celery Worker +```bash +cd backend +celery -A eusrr_backend worker -l info +``` + +### Celery Beat (для periodic tasks) +```bash +celery -A eusrr_backend beat -l info +``` + +### Одной командой (для разработки) +```bash +celery -A eusrr_backend worker -B -l info +``` + +--- + +## Мониторинг + +### Flower (Celery monitoring) +```bash +celery -A eusrr_backend flower +# http://localhost:5555 +``` + +### Проверка задач +```python +from celery import current_app + +# Посмотреть запланированные задачи +current_app.control.inspect().scheduled() + +# Посмотреть активные задачи +current_app.control.inspect().active() +``` + +--- + +## Расширение + +### Добавить новый тип (например, декрет) + +**1. Обновить маппинг**: +```python +# requests_app/signals.py +SCHEDULED_ACTION_MAPPING = { + "vacation": "on_leave", + "sick_leave": "on_sick_leave", + "maternity": "on_maternity", # ← Добавить +} +``` + +**2. Обновить tasks.py**: +```python +# В create_scheduled_action +action_mapping = { + 'vacation': 'on_leave', + 'sick_leave': 'on_sick_leave', + 'maternity': 'on_maternity', # ← Добавить +} + +# В schedule_auto_return +return_mapping = { + 'vacation': 'returned_from_leave', + 'sick_leave': 'returned_from_leave', + 'maternity': 'returned_from_maternity', # ← Добавить +} +``` + +**3. Добавить тип в RequestType**: +```python +# requests_app/enums.py +class RequestType(TextChoices): + # ... + MATERNITY = "maternity", "Декрет" +``` + +**Всё!** Автоматизация заработает для нового типа. + +--- + +## Логирование + +Все действия логируются: + +``` +INFO: Scheduled EmployeeAction creation for Request #123 at 2026-04-01 00:00:00 +INFO: Created scheduled EmployeeAction #456 (on_leave) for Request #123 +INFO: Created auto-return EmployeeAction #457 (returned_from_leave) for Request #123 +INFO: cleanup_missed_returns completed: 2 actions created +``` + +--- + +## Преимущества решения + +✅ **Автоматизация** - все работает без ручного вмешательства +✅ **Гибкость** - можно отменить заявку до date_from +✅ **Надежность** - retry + cleanup task +✅ **Масштабируемость** - Celery + Redis +✅ **Audittrail** - все события с метаданными +✅ **Расширяемость** - легко добавить новые типы +✅ **Event-driven** - декаплинг через сигналы + +--- + +**Автор**: GitHub Copilot +**Дата**: 17 марта 2026 г.