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/management/__init__.py b/backend/requests_app/management/__init__.py new file mode 100644 index 00000000..65cb83ac --- /dev/null +++ b/backend/requests_app/management/__init__.py @@ -0,0 +1 @@ +# Management commands package diff --git a/backend/requests_app/management/commands/__init__.py b/backend/requests_app/management/commands/__init__.py new file mode 100644 index 00000000..2c1c7c15 --- /dev/null +++ b/backend/requests_app/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/backend/requests_app/management/commands/migrate_requests_to_actions.py b/backend/requests_app/management/commands/migrate_requests_to_actions.py new file mode 100644 index 00000000..6dcc0897 --- /dev/null +++ b/backend/requests_app/management/commands/migrate_requests_to_actions.py @@ -0,0 +1,183 @@ +""" +Management команда для миграции существующих заявок в кадровые события. + +Использование: + python manage.py migrate_requests_to_actions [--dry-run] +""" +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from requests_app.models import Request +from requests_app.enums import RequestStatus +from employees.models import EmployeeAction +from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING + + +class Command(BaseCommand): + help = 'Создает EmployeeAction для всех одобренных заявок, у которых их еще нет' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Показать что будет создано, но не создавать', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + + if dry_run: + self.stdout.write(self.style.WARNING('=== DRY RUN MODE ===')) + + # Объединяем все маппинги + all_mappings = {**IMMEDIATE_ACTION_MAPPING, **SCHEDULED_ACTION_MAPPING} + + # Находим все одобренные заявки с типами, которые требуют EmployeeAction + approved_requests = Request.objects.filter( + status=RequestStatus.APPROVED, + type__in=all_mappings.keys() + ).select_related('employee', 'approver').order_by('created_at') + + total_requests = approved_requests.count() + self.stdout.write( + f'Найдено {total_requests} одобренных заявок типов: {list(all_mappings.keys())}' + ) + + created_count = 0 + skipped_count = 0 + errors = [] + + for request in approved_requests: + action_type = all_mappings.get(request.type) + if not action_type: + continue + + # Проверяем, существует ли уже событие для этой заявки + existing = EmployeeAction.objects.filter( + extra__request_id=request.id, + action=action_type + ).exists() + + if existing: + skipped_count += 1 + self.stdout.write( + self.style.WARNING( + f' SKIP: Request #{request.id} ({request.type}) - ' + f'EmployeeAction уже существует' + ) + ) + continue + + # Определяем дату события + action_date = request.date_from or request.decided_at or request.created_at or timezone.now() + + # Формируем комментарий + action_comment = f"Заявление #{request.id}" + if request.comment: + action_comment += f": {request.comment[:200]}" + + # Готовим extra данные + extra_data = { + 'request_id': request.id, + 'approved_by': request.approver.id if request.approver else None, + 'migrated': True, # Помечаем что создано миграцией + 'migration_date': timezone.now().isoformat() + } + + if dry_run: + # action_date может быть date или datetime + date_str = action_date.date() if hasattr(action_date, 'date') else action_date + self.stdout.write( + self.style.SUCCESS( + f' WOULD CREATE: Request #{request.id} ({request.type}) → ' + f'EmployeeAction ({action_type}) for {request.employee} ' + f'on {date_str}' + ) + ) + created_count += 1 + else: + try: + with transaction.atomic(): + action = EmployeeAction.objects.create( + employee=request.employee, + action=action_type, + date=action_date, + comment=action_comment, + extra=extra_data + ) + + # Применяем эффекты (деактивация, LDAP sync) + self._apply_effects(action) + + created_count += 1 + self.stdout.write( + self.style.SUCCESS( + f' ✓ Created: EmployeeAction #{action.id} ({action_type}) ' + f'from Request #{request.id} for {request.employee}' + ) + ) + + except Exception as e: + errors.append((request.id, str(e))) + self.stdout.write( + self.style.ERROR( + f' ✗ Failed: Request #{request.id} - {e}' + ) + ) + + # Итоговая статистика + self.stdout.write('\n' + '='*70) + if dry_run: + self.stdout.write(self.style.WARNING('DRY RUN - изменения не применены')) + + self.stdout.write( + self.style.SUCCESS( + f'Обработано заявок: {total_requests}' + ) + ) + + if created_count: + verb = 'Будет создано' if dry_run else 'Создано' + self.stdout.write( + self.style.SUCCESS( + f'{verb} событий: {created_count}' + ) + ) + + if skipped_count: + self.stdout.write( + self.style.WARNING( + f'Пропущено (уже существуют): {skipped_count}' + ) + ) + + if errors: + self.stdout.write( + self.style.ERROR( + f'\nОшибки ({len(errors)}):' + ) + ) + for req_id, error in errors: + self.stdout.write(f' Request #{req_id}: {error}') + + if not dry_run and created_count > 0: + self.stdout.write( + self.style.SUCCESS( + '\n✓ Миграция завершена успешно!' + ) + ) + + def _apply_effects(self, action): + """Применяет эффекты кадрового события (как в EmployeeActionViewSet).""" + try: + from api.v1.employees.views.actions import EmployeeActionViewSet + viewset = EmployeeActionViewSet() + viewset._apply_effects(action) + except Exception as e: + # Не критично если эффекты не применились + self.stdout.write( + self.style.WARNING( + f' Warning: Could not apply effects for action #{action.id}: {e}' + ) + ) 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/backend/requests_app/tests/conftest.py b/backend/requests_app/tests/conftest.py new file mode 100644 index 00000000..00229d56 --- /dev/null +++ b/backend/requests_app/tests/conftest.py @@ -0,0 +1,41 @@ +""" +Conftest для тестов requests_app - импортирует общие фикстуры. +""" +import pytest +from django.conf import settings + + +@pytest.fixture +def user_factory(): + """Фабрика для создания пользователей.""" + from employees.models import Employee + + def _make( + email: str = None, + *, + first_name: str = "Test", + last_name: str = "User", + active: bool = True, + **extra + ): + import uuid + if not email: + email = f"test+{uuid.uuid4().hex[:8]}@example.com" + + # Генерируем уникальные phone_number и username + unique_id = uuid.uuid4().hex[:8] + if 'phone_number' not in extra: + extra['phone_number'] = f"+7900{unique_id}" + if 'username' not in extra: + extra['username'] = f"user_{unique_id}" + + user = Employee.objects.create( + email=email, + first_name=first_name, + last_name=last_name, + is_active=active, + **extra + ) + return user + + return _make diff --git a/backend/requests_app/tests/test_signals.py b/backend/requests_app/tests/test_signals.py new file mode 100644 index 00000000..ae475ec8 --- /dev/null +++ b/backend/requests_app/tests/test_signals.py @@ -0,0 +1,298 @@ +""" +Тесты для автоматического создания EmployeeAction из заявок (signals). +""" +import pytest +from datetime import date, timedelta +from unittest.mock import patch, MagicMock +from django.utils import timezone + +from requests_app.models import Request +from requests_app.enums import RequestStatus +from employees.models import EmployeeAction +from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING + + +@pytest.mark.django_db +class TestRequestSignals: + """Тесты сигналов создания EmployeeAction из Request.""" + + def test_immediate_action_dismissal(self, user_factory): + """Увольнение создаёт EmployeeAction немедленно при одобрении.""" + employee = user_factory() + approver = user_factory() + + # Создаём заявку на увольнение + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + date_from=date.today(), + comment='Увольнение по собственному желанию' + ) + + # Проверяем что события ещё нет + assert not EmployeeAction.objects.filter( + extra__request_id=request.id + ).exists() + + # Одобряем заявку + request.status = RequestStatus.APPROVED + request.approver = approver + request.decided_at = timezone.now() + request.save() + + # Проверяем что событие создалось + action = EmployeeAction.objects.get( + employee=employee, + action='dismissed', + extra__request_id=request.id + ) + + assert action.action == 'dismissed' + assert action.extra['request_id'] == request.id + assert action.extra['approved_by'] == approver.id + assert action.extra['immediate'] is True + assert 'Заявление #' in action.comment + + def test_immediate_action_transfer(self, user_factory): + """Перевод создаёт EmployeeAction немедленно при одобрении.""" + employee = user_factory() + approver = user_factory() + + request = Request.objects.create( + employee=employee, + type='transfer', + status=RequestStatus.PENDING, + date_from=date.today(), + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # Проверяем событие + action = EmployeeAction.objects.get( + employee=employee, + action='transferred', + extra__request_id=request.id + ) + + assert action.action == 'transferred' + assert action.extra['immediate'] is True + + @patch('employees.signals._schedule_delayed_action') + def test_scheduled_action_vacation(self, mock_schedule, user_factory): + """Отпуск запускает планирование через Celery.""" + employee = user_factory() + approver = user_factory() + future_date = date.today() + timedelta(days=7) + + request = Request.objects.create( + employee=employee, + type='vacation', + status=RequestStatus.PENDING, + date_from=future_date, + date_to=future_date + timedelta(days=10), + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # Проверяем что вызвалась функция планирования + mock_schedule.assert_called_once() + called_request = mock_schedule.call_args[0][0] + assert called_request.id == request.id + + @patch('employees.signals._schedule_delayed_action') + def test_scheduled_action_sick_leave(self, mock_schedule, user_factory): + """Больничный запускает планирование через Celery.""" + employee = user_factory() + approver = user_factory() + future_date = date.today() + timedelta(days=3) + + request = Request.objects.create( + employee=employee, + type='sick_leave', + status=RequestStatus.PENDING, + date_from=future_date, + ) + + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + mock_schedule.assert_called_once() + + def test_no_duplicate_actions(self, user_factory): + """Повторное сохранение одобренной заявки не создаёт дубликаты.""" + employee = user_factory() + approver = user_factory() + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + date_from=date.today(), + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # Первое событие создалось + assert EmployeeAction.objects.filter( + employee=employee, + action='dismissed', + extra__request_id=request.id + ).count() == 1 + + # Меняем комментарий и сохраняем снова + request.comment = 'Обновлённый комментарий' + request.save() + + # Дубликат не создался + assert EmployeeAction.objects.filter( + employee=employee, + action='dismissed', + extra__request_id=request.id + ).count() == 1 + + def test_signal_not_fired_on_creation(self, user_factory): + """Сигнал не срабатывает при создании (только при одобрении).""" + employee = user_factory() + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + ) + + # События нет + assert not EmployeeAction.objects.filter( + extra__request_id=request.id + ).exists() + + def test_signal_not_fired_on_rejection(self, user_factory): + """Сигнал не срабатывает при отклонении.""" + employee = user_factory() + approver = user_factory() + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + ) + + # Отклоняем + request.status = RequestStatus.REJECTED + request.approver = approver + request.save() + + # События нет + assert not EmployeeAction.objects.filter( + extra__request_id=request.id + ).exists() + + def test_unknown_request_type_no_action(self, user_factory): + """Неизвестный тип заявки не создаёт события.""" + employee = user_factory() + approver = user_factory() + + # Используем тип, который не в маппингах + request = Request.objects.create( + employee=employee, + type='other', # Не в IMMEDIATE/SCHEDULED маппингах + status=RequestStatus.PENDING, + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # События нет + assert not EmployeeAction.objects.filter( + employee=employee, + extra__request_id=request.id + ).exists() + + def test_action_date_from_request_date_from(self, user_factory): + """Дата события берётся из date_from заявки.""" + employee = user_factory() + approver = user_factory() + specific_date = date(2026, 6, 15) + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + date_from=specific_date, + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + action = EmployeeAction.objects.get( + employee=employee, + action='dismissed', + extra__request_id=request.id + ) + + # Проверяем дату (может быть datetime, поэтому берём .date()) + action_date = action.date.date() if hasattr(action.date, 'date') else action.date + assert action_date == specific_date + + @patch('employees.signals._apply_action_effects') + def test_effects_applied_for_immediate_actions(self, mock_apply, user_factory): + """Эффекты применяются для немедленных действий.""" + employee = user_factory() + approver = user_factory() + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # Проверяем что _apply_action_effects была вызвана + mock_apply.assert_called_once() + created_action = mock_apply.call_args[0][0] + assert created_action.action == 'dismissed' + + +@pytest.mark.django_db +class TestRequestMappings: + """Тесты корректности маппингов типов заявок.""" + + def test_immediate_mapping_complete(self): + """Проверка что все немедленные маппинги правильные.""" + assert IMMEDIATE_ACTION_MAPPING == { + 'transfer': 'transferred', + 'dismissal': 'dismissed', + } + + def test_scheduled_mapping_complete(self): + """Проверка что все отложенные маппинги правильные.""" + assert SCHEDULED_ACTION_MAPPING == { + 'vacation': 'on_leave', + 'sick_leave': 'on_sick_leave', + } + + def test_no_overlap_in_mappings(self): + """Проверка что маппинги не пересекаются.""" + immediate_keys = set(IMMEDIATE_ACTION_MAPPING.keys()) + scheduled_keys = set(SCHEDULED_ACTION_MAPPING.keys()) + + assert immediate_keys.isdisjoint(scheduled_keys), \ + "Request types должны быть либо immediate, либо scheduled, но не оба" 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 г.