test: добавлены тесты для сигналов и migration команда
- Добавлена management команда migrate_requests_to_actions для миграции существующих заявок - Добавлен conftest.py с фикстурой user_factory для тестов requests_app - Добавлены 13 тестов для проверки автоматизации создания EmployeeAction - Покрытие: немедленные/отложенные события, дубликаты, условия срабатывания, маппинги
This commit is contained in:
parent
c5a8455421
commit
46daefcdec
1
backend/requests_app/management/__init__.py
Normal file
1
backend/requests_app/management/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Management commands package
|
||||
1
backend/requests_app/management/commands/__init__.py
Normal file
1
backend/requests_app/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Management commands
|
||||
@ -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}'
|
||||
)
|
||||
)
|
||||
41
backend/requests_app/tests/conftest.py
Normal file
41
backend/requests_app/tests/conftest.py
Normal file
@ -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
|
||||
298
backend/requests_app/tests/test_signals.py
Normal file
298
backend/requests_app/tests/test_signals.py
Normal file
@ -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, но не оба"
|
||||
Loading…
x
Reference in New Issue
Block a user