Merge branch 'develop'
This commit is contained in:
commit
ea61aef99a
@ -42,6 +42,7 @@ class ChatListSerializer(serializers.ModelSerializer):
|
||||
last_message = serializers.SerializerMethodField()
|
||||
unread_count = serializers.SerializerMethodField()
|
||||
participant_names = serializers.SerializerMethodField()
|
||||
member_ids = serializers.SerializerMethodField()
|
||||
is_pinned = serializers.SerializerMethodField()
|
||||
notifications_enabled = serializers.SerializerMethodField()
|
||||
last_read_message_id = serializers.SerializerMethodField()
|
||||
@ -59,7 +60,7 @@ class ChatListSerializer(serializers.ModelSerializer):
|
||||
# DEPRECATED: для обратной совместимости
|
||||
'is_main',
|
||||
# Messages & status
|
||||
'last_message', 'unread_count', 'participant_names',
|
||||
'last_message', 'unread_count', 'participant_names', 'member_ids',
|
||||
'is_pinned', 'notifications_enabled', 'last_read_message_id'
|
||||
]
|
||||
|
||||
@ -86,13 +87,17 @@ class ChatListSerializer(serializers.ModelSerializer):
|
||||
return None
|
||||
|
||||
def get_participant_names(self, obj):
|
||||
"""Имена участников (для приватных чатов)
|
||||
MIGRATION: Используем memberships вместо participants
|
||||
"""
|
||||
"""Имена участников (для приватных чатов)"""
|
||||
if obj.type == 'private':
|
||||
active_members = obj.memberships.filter(is_active=True).select_related('user')[:5]
|
||||
return [m.user.get_full_name() for m in active_members]
|
||||
return []
|
||||
|
||||
def get_member_ids(self, obj):
|
||||
"""ID участников (из prefetch, для поиска существующего чата)"""
|
||||
return list(
|
||||
obj.memberships.filter(is_active=True).values_list('user_id', flat=True)
|
||||
)
|
||||
|
||||
def get_is_pinned(self, obj):
|
||||
"""Закреплен ли чат для текущего пользователя (из prefetch)"""
|
||||
|
||||
@ -89,7 +89,6 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
user = self.request.user
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
# MIGRATION: Используем только memberships вместо participants
|
||||
queryset = Chat.objects.filter(
|
||||
Q(memberships__user=user, memberships__is_active=True)
|
||||
| Q(include_all_users=True)
|
||||
@ -97,7 +96,6 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
).select_related(
|
||||
'created_by', 'context_content_type'
|
||||
).prefetch_related(
|
||||
'participants',
|
||||
# Prefetch ChatMembership с информацией о пользователе
|
||||
Prefetch(
|
||||
'memberships',
|
||||
@ -132,31 +130,38 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
# Устанавливаем создателя
|
||||
chat = serializer.save(created_by=request.user)
|
||||
|
||||
# MIGRATION: Создаем membership для всех типов кроме глобальных (убрали participants.add)
|
||||
if chat.type != 'global':
|
||||
# Для чатов с управлением участниками создаем membership с ролью admin
|
||||
if chat.type in ['group', 'channel', 'announcement']:
|
||||
ChatMembership.objects.create(
|
||||
chat=chat,
|
||||
user=request.user,
|
||||
role='admin',
|
||||
invited_by=request.user,
|
||||
is_active=True
|
||||
)
|
||||
# Для других типов (private, direct) создаем обычного участника
|
||||
else:
|
||||
ChatMembership.objects.create(
|
||||
chat=chat,
|
||||
user=request.user,
|
||||
role='member',
|
||||
invited_by=request.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Для глобальных чатов устанавливаем include_all_users=True
|
||||
if chat.type == 'global':
|
||||
chat.include_all_users = True
|
||||
chat.save()
|
||||
else:
|
||||
role = 'admin' if chat.type in ['group', 'channel', 'announcement'] else 'member'
|
||||
ChatMembership.objects.create(
|
||||
chat=chat,
|
||||
user=request.user,
|
||||
role=role,
|
||||
invited_by=request.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Добавляем остальных участников из запроса
|
||||
participant_ids = request.data.get('participants', [])
|
||||
if participant_ids:
|
||||
for user_id in participant_ids:
|
||||
if user_id == request.user.id:
|
||||
continue
|
||||
try:
|
||||
participant = User.objects.get(id=user_id)
|
||||
ChatMembership.objects.get_or_create(
|
||||
chat=chat,
|
||||
user=participant,
|
||||
defaults={
|
||||
'role': 'member',
|
||||
'invited_by': request.user,
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
logger.warning(f"User {user_id} not found when creating chat {chat.id}")
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
|
||||
@ -551,7 +551,6 @@ class ChatConsumerMixin:
|
||||
chat_ids = list(
|
||||
Chat.objects.filter(
|
||||
Q(type="global")
|
||||
| Q(type="private", participants=user)
|
||||
| Q(id__in=membership_chat_ids)
|
||||
| Q(include_all_users=True)
|
||||
).values_list("id", flat=True).distinct()
|
||||
@ -582,21 +581,21 @@ class ChatConsumerMixin:
|
||||
return True
|
||||
|
||||
if chat.type == "private":
|
||||
return chat.participants.filter(pk=user.pk).exists()
|
||||
|
||||
return ChatMembership.objects.filter(
|
||||
chat=chat, user=user, is_active=True
|
||||
).exists()
|
||||
|
||||
if chat.type == "group":
|
||||
return (
|
||||
chat.participants.filter(pk=user.pk).exists()
|
||||
or ChatMembership.objects.filter(chat=chat, user=user).exists()
|
||||
)
|
||||
|
||||
return ChatMembership.objects.filter(
|
||||
chat=chat, user=user, is_active=True
|
||||
).exists()
|
||||
|
||||
if chat.type in ("channel", "announcement", "comments"):
|
||||
if chat.include_all_users:
|
||||
return user.is_active
|
||||
return (
|
||||
chat.participants.filter(pk=user.pk).exists()
|
||||
or ChatMembership.objects.filter(chat=chat, user=user).exists()
|
||||
)
|
||||
return ChatMembership.objects.filter(
|
||||
chat=chat, user=user, is_active=True
|
||||
).exists()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@ -62,9 +62,9 @@ def user_can_access_chat(chat: Chat, user) -> bool:
|
||||
|
||||
Правила доступа по типам чатов:
|
||||
- global: доступ всем
|
||||
- private: только участники (participants)
|
||||
- group: участники (participants) или ChatMembership
|
||||
- channel/announcement: include_all_users или participants/membership
|
||||
- private: активное ChatMembership
|
||||
- group: активное ChatMembership (или context-based resolver)
|
||||
- channel/announcement: include_all_users или активное ChatMembership
|
||||
- comments: context-based доступ через get_participants()
|
||||
|
||||
Args:
|
||||
@ -84,10 +84,9 @@ def user_can_access_chat(chat: Chat, user) -> bool:
|
||||
return True
|
||||
|
||||
if chat.type == "private":
|
||||
# Личные чаты только через participants
|
||||
# Обходим prefetch cache используя прямой запрос
|
||||
from .models import Chat
|
||||
return Chat.objects.filter(pk=chat.pk, participants=user).exists()
|
||||
return ChatMembership.objects.filter(
|
||||
chat=chat, user=user, is_active=True
|
||||
).exists()
|
||||
|
||||
if chat.type == "group":
|
||||
# Групповые чаты с контекстным объектом используют resolver
|
||||
@ -102,34 +101,21 @@ def user_can_access_chat(chat: Chat, user) -> bool:
|
||||
f"[user_can_access_chat] Failed to resolve for chat "
|
||||
f"{chat.id}: {e}"
|
||||
)
|
||||
|
||||
# Обычные групповые чаты через participants ИЛИ ChatMembership
|
||||
from .models import Chat
|
||||
in_participants = Chat.objects.filter(
|
||||
pk=chat.pk, participants=user
|
||||
return ChatMembership.objects.filter(
|
||||
chat=chat, user=user, is_active=True
|
||||
).exists()
|
||||
in_membership = ChatMembership.objects.filter(
|
||||
chat=chat, user=user
|
||||
).exists()
|
||||
return in_participants or in_membership
|
||||
|
||||
if chat.type in ("channel", "announcement"):
|
||||
# Для каналов и объявлений может быть include_all_users или membership
|
||||
if chat.include_all_users:
|
||||
return user.is_active
|
||||
# Проверяем и participants и membership для гибкости
|
||||
from .models import Chat
|
||||
return (
|
||||
Chat.objects.filter(pk=chat.pk, participants=user).exists()
|
||||
or ChatMembership.objects.filter(chat=chat, user=user).exists()
|
||||
)
|
||||
return ChatMembership.objects.filter(
|
||||
chat=chat, user=user, is_active=True
|
||||
).exists()
|
||||
|
||||
if chat.type == "comments":
|
||||
# Чаты-комментарии ВСЕГДА используют context-based доступ
|
||||
# Чаты-комментарии используют context-based доступ через get_participants()
|
||||
if chat.context_object_id:
|
||||
try:
|
||||
# get_participants() вызовет resolver который учитывает
|
||||
# права доступа к контекстному объекту
|
||||
participants = chat.get_participants()
|
||||
return participants.filter(pk=user.pk).exists()
|
||||
except Exception as e:
|
||||
@ -140,12 +126,8 @@ def user_can_access_chat(chat: Chat, user) -> bool:
|
||||
f"comments chat {chat.id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Fallback для старых чатов без контекста
|
||||
from .models import Chat
|
||||
return (
|
||||
Chat.objects.filter(pk=chat.pk, participants=user).exists()
|
||||
or ChatMembership.objects.filter(chat=chat, user=user).exists()
|
||||
)
|
||||
return ChatMembership.objects.filter(
|
||||
chat=chat, user=user, is_active=True
|
||||
).exists()
|
||||
|
||||
return False
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -0,0 +1 @@
|
||||
default_app_config = 'requests_app.apps.RequestsAppConfig'
|
||||
@ -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: регистрация предикатов и правил доступа
|
||||
|
||||
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}'
|
||||
)
|
||||
)
|
||||
270
backend/requests_app/tasks.py
Normal file
270
backend/requests_app/tasks.py
Normal file
@ -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
|
||||
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, но не оба"
|
||||
318
docs/completed/REQUESTS_CELERY_AUTOMATION.md
Normal file
318
docs/completed/REQUESTS_CELERY_AUTOMATION.md
Normal file
@ -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 г.
|
||||
@ -4,15 +4,21 @@ import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import type { User } from "@/types/api";
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const router = useRouter();
|
||||
const { user: currentUser } = useUser();
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [creatingChatFor, setCreatingChatFor] = useState<number | null>(null);
|
||||
const loaderRef = useRef<HTMLDivElement | null>(null);
|
||||
const isFetchingRef = useRef(false);
|
||||
|
||||
@ -28,7 +34,11 @@ export default function EmployeesPage() {
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.getEmployees({ page: pageToLoad, limit: 20 });
|
||||
const response = await apiClient.getEmployees({
|
||||
page: pageToLoad,
|
||||
limit: 20,
|
||||
is_active: true
|
||||
});
|
||||
const nextChunk = response.results || [];
|
||||
|
||||
setEmployees((prev) => (append ? [...prev, ...nextChunk] : nextChunk));
|
||||
@ -48,6 +58,47 @@ export default function EmployeesPage() {
|
||||
loadEmployees(1, false);
|
||||
}, []);
|
||||
|
||||
// Создание чата с сотрудником
|
||||
const handleStartChat = async (employee: User, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!currentUser || creatingChatFor === employee.id) return;
|
||||
if (currentUser.id === employee.id) return; // Не создаем чат с собой
|
||||
|
||||
setCreatingChatFor(employee.id);
|
||||
try {
|
||||
// Ищем существующий приватный чат с этим пользователем
|
||||
const chatsResponse = await apiClient.getChats();
|
||||
const allChats = chatsResponse.results || chatsResponse;
|
||||
|
||||
const existingChat = allChats.find((chat: any) => {
|
||||
if (chat.type !== 'private') return false;
|
||||
|
||||
const memberIds: number[] = chat.member_ids || [];
|
||||
return memberIds.length === 2 &&
|
||||
memberIds.includes(currentUser.id) &&
|
||||
memberIds.includes(employee.id);
|
||||
});
|
||||
|
||||
if (existingChat) {
|
||||
router.push(`/messages/${existingChat.id}`);
|
||||
} else {
|
||||
const chat = await apiClient.createChat({
|
||||
type: 'private',
|
||||
name: 'Диалог',
|
||||
participants: [employee.id]
|
||||
});
|
||||
router.push(`/messages/${chat.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Ошибка создания чата:", err);
|
||||
alert("Не удалось открыть чат");
|
||||
} finally {
|
||||
setCreatingChatFor(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || loading) return;
|
||||
|
||||
@ -111,14 +162,11 @@ export default function EmployeesPage() {
|
||||
const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim();
|
||||
const initials = `${employee.last_name?.[0] || ''}${employee.first_name?.[0] || ''}`;
|
||||
const position = employee.position?.name || 'Сотрудник';
|
||||
const isOnline = employee.is_active;
|
||||
const statusText = isOnline ? 'Активен' : 'Неактивен';
|
||||
const dotColor = isOnline ? 'bg-green-500' : 'bg-gray-400';
|
||||
const statusColor = isOnline ? 'text-green-600' : 'text-gray-500';
|
||||
const isCurrentUser = currentUser?.id === employee.id;
|
||||
|
||||
return (
|
||||
<Link key={employee.id} href={`/users/${employee.id}`} className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 transition hover:bg-gray-50">
|
||||
<div className="relative h-12 w-12">
|
||||
<div key={employee.id} className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<Link href={`/users/${employee.id}`} className="flex flex-1 items-center gap-4 transition hover:opacity-80">
|
||||
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{employee.avatar ? (
|
||||
<img src={employee.avatar} alt={fullName} className="h-full w-full object-cover" />
|
||||
@ -126,25 +174,29 @@ export default function EmployeesPage() {
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
{isOnline ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">{fullName}</p>
|
||||
<p className="text-xs text-gray-500">{position}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
|
||||
<span className={statusColor}>{statusText}</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">{fullName}</p>
|
||||
<p className="text-xs text-gray-500">{position}</p>
|
||||
{employee.email && (
|
||||
<>
|
||||
<span className="mx-1 text-gray-300">•</span>
|
||||
<span className="text-gray-500">{employee.email}</span>
|
||||
</>
|
||||
<p className="mt-1 text-xs text-gray-500">{employee.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
{!isCurrentUser && (
|
||||
<button
|
||||
onClick={(e) => handleStartChat(employee, e)}
|
||||
disabled={creatingChatFor === employee.id}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-100 text-sky-600 transition hover:bg-sky-200 disabled:opacity-50"
|
||||
title="Написать сообщение"
|
||||
>
|
||||
{creatingChatFor === employee.id ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-sky-600 border-t-transparent" />
|
||||
) : (
|
||||
<MessageCircle size={18} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@ -1188,12 +1188,8 @@ export default function MessageDialogPage() {
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/messages/${chatId}/settings`} className="hover:opacity-80 transition">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{getChatTitle(chat, user?.id)}</p>
|
||||
{/* Индикатор WebSocket соединения */}
|
||||
{isConnected && (
|
||||
<span className="inline-flex h-2 w-2 rounded-full bg-green-500" title="Real-time подключен"></span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}</p>
|
||||
</Link>
|
||||
@ -1302,9 +1298,6 @@ export default function MessageDialogPage() {
|
||||
getMessageInitials(message)
|
||||
)}
|
||||
</div>
|
||||
{message.author?.is_active ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -161,15 +161,6 @@ function getChatSortTimestamp(chat: Chat): number {
|
||||
return Number.isNaN(chatCreatedTs) ? 0 : chatCreatedTs;
|
||||
}
|
||||
|
||||
function isChatOnline(chat: Chat, currentUserId?: number): boolean {
|
||||
const chatKind = chat.chat_type || chat.type;
|
||||
if (chatKind !== "direct" && chatKind !== "private") return false;
|
||||
const otherParticipant = (chat.participants || [])
|
||||
.filter((p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null)
|
||||
.find((p) => p.id !== currentUserId);
|
||||
return Boolean(otherParticipant?.is_active);
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
@ -710,10 +701,6 @@ export default function MessagesPage() {
|
||||
<span className="absolute -bottom-0.5 -left-0.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-sky-600 ring-2 ring-white">
|
||||
{getChatTypeIcon(chat)}
|
||||
</span>
|
||||
{/* Онлайн-статус */}
|
||||
{isChatOnline(chat, user?.id) ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
{/* Счетчик непрочитанных */}
|
||||
{(chat.unread_count ?? 0) > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 z-10 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-red-500 px-1.5 text-[10px] font-bold text-white ring-2 ring-white">
|
||||
|
||||
@ -534,10 +534,6 @@ export default function Home() {
|
||||
const authorInitials = post.author
|
||||
? `${post.author.last_name?.[0] || ''}${post.author.first_name?.[0] || ''}`
|
||||
: 'А';
|
||||
const isAuthorOnline =
|
||||
typeof post.author?.is_active === 'boolean'
|
||||
? post.author.is_active
|
||||
: Boolean(post.author?.id && user?.id && post.author.id === user.id && user.is_active);
|
||||
|
||||
const postText = (post.body || post.content || "").trim();
|
||||
|
||||
@ -564,9 +560,6 @@ export default function Home() {
|
||||
authorInitials
|
||||
)}
|
||||
</div>
|
||||
{isAuthorOnline ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{authorName}</p>
|
||||
|
||||
@ -203,7 +203,6 @@ export default function ProfilePage() {
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">{initials}</div>
|
||||
)}
|
||||
{user.is_active ? <span className="absolute -bottom-0.5 -right-0.5 z-20 h-3.5 w-3.5 rounded-full bg-sky-400 ring-2 ring-white" /> : null}
|
||||
</div>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -189,9 +189,6 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
|
||||
userInitials
|
||||
)}
|
||||
</div>
|
||||
{user?.is_active ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
{/* Меню пользователя */}
|
||||
{userMenuOpen && (
|
||||
<div className="absolute right-0 top-12 z-[60] w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
|
||||
@ -605,9 +602,6 @@ export function AppShell({ children }: AppShellProps) {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-gray-900">{userName}</p>
|
||||
{user?.is_active && (
|
||||
<p className="text-xs text-sky-600">Онлайн</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
|
||||
@ -246,12 +246,13 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Сотрудники
|
||||
async getEmployees(params?: { search?: string; department?: string; page?: number; limit?: number }): Promise<any> {
|
||||
async getEmployees(params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.department) queryParams.append('department', params.department);
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params?.is_active !== undefined) queryParams.append('active', params.is_active.toString());
|
||||
|
||||
const url = `/api/v1/employees/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
return this.request(url);
|
||||
@ -261,6 +262,74 @@ class ApiClient {
|
||||
return this.request(`/api/v1/employees/${id}/`);
|
||||
}
|
||||
|
||||
async updateEmployee(id: number | string, data: {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
patronymic?: string;
|
||||
email?: string;
|
||||
phone_number?: string;
|
||||
telegram?: string;
|
||||
whatsapp?: string;
|
||||
wechat?: string;
|
||||
birth_date?: string;
|
||||
gender?: string;
|
||||
position_id?: number;
|
||||
}): Promise<any> {
|
||||
return this.request(`/api/v1/employees/${id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async uploadEmployeeAvatar(id: number | string, file: File): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
return this.request(`/api/v1/employees/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать кадровое событие (action) для сотрудника
|
||||
*/
|
||||
async createEmployeeAction(data: {
|
||||
employee: number;
|
||||
action: string;
|
||||
date: string;
|
||||
comment?: string;
|
||||
}): Promise<any> {
|
||||
return this.request('/api/v1/employee-actions/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить кадровое событие
|
||||
*/
|
||||
async updateEmployeeAction(actionId: number, data: {
|
||||
action?: string;
|
||||
date?: string;
|
||||
comment?: string;
|
||||
}): Promise<any> {
|
||||
return this.request(`/api/v1/employee-actions/${actionId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить кадровое событие
|
||||
*/
|
||||
async deleteEmployeeAction(actionId: number): Promise<any> {
|
||||
return this.request(`/api/v1/employee-actions/${actionId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Отделы
|
||||
async getDepartments(params?: { search?: string; page?: number; limit?: number }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
@ -8,10 +8,14 @@ export interface Skill {
|
||||
export interface EmployeeAction {
|
||||
id: number;
|
||||
employee: number;
|
||||
action_type: string;
|
||||
description?: string;
|
||||
action: string;
|
||||
action_display?: string;
|
||||
date: string;
|
||||
created_at: string;
|
||||
date_display?: string;
|
||||
comment?: string;
|
||||
extra?: Record<string, any>;
|
||||
history?: any;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface EmployeeDepartment {
|
||||
@ -33,11 +37,11 @@ export interface User {
|
||||
position?: Position;
|
||||
departments?: EmployeeDepartment[]; // массив отделов с ролями (было department)
|
||||
avatar?: string;
|
||||
is_active: boolean;
|
||||
is_active: boolean; // ВАЖНО: активный сотрудник (не уволен), НЕ онлайн статус!
|
||||
telegram?: string;
|
||||
whatsapp?: string;
|
||||
wechat?: string;
|
||||
gender?: string;
|
||||
gender?: string | number; // 1 - мужской, 2 - женский, 0 - не указан
|
||||
birth_date?: string;
|
||||
skills?: Skill[];
|
||||
actions?: EmployeeAction[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user