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/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, но не оба"