diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index 0c62ac4a..ec86508c 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -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)""" diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 95f2607f..a8846ebf 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -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( diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 9d2f09a8..37b30506 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -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 diff --git a/backend/communications/utils.py b/backend/communications/utils.py index e0ebe7fa..5812c8ca 100644 --- a/backend/communications/utils.py +++ b/backend/communications/utils.py @@ -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 diff --git a/backend/employees/signals.py b/backend/employees/signals.py index c727dcd1..4e454376 100644 --- a/backend/employees/signals.py +++ b/backend/employees/signals.py @@ -1,5 +1,9 @@ # users/signals.py + +import logging import os +from datetime import datetime + from django.contrib.contenttypes.models import ContentType from django.core.files.base import ContentFile from django.db.models import Q @@ -10,6 +14,25 @@ from .constants import ACTION_HIRED from .models import Employee, EmployeeAction, Department +logger = logging.getLogger(__name__) + + +# ============================================================ +# Автоматизация создания кадровых событий из заявок +# ============================================================ + +# Немедленные события (создаются сразу при одобрении) +IMMEDIATE_ACTION_MAPPING = { + "transfer": "transferred", # Перевод → Переведен + "dismissal": "dismissed", # Увольнение → Уволен +} + +# Отложенные события (создаются по Celery в date_from) +SCHEDULED_ACTION_MAPPING = { + "vacation": "on_leave", # Отпуск → В отпуске + "sick_leave": "on_sick_leave", # Больничный → На больничном +} + @receiver(post_save, sender=Employee) def create_hired_action(sender, instance: Employee, created, **kwargs): @@ -60,3 +83,174 @@ def create_main_department_chat(sender, instance, created, **kwargs): except ImportError: # communications app не установлен - пропускаем pass + + +# ============================================================ +# Обработчики заявок (requests_app) +# ============================================================ + +@receiver(pre_save, sender='requests_app.Request') +def track_request_status_change(sender, instance, **kwargs): + """Сохраняем старый статус перед обновлением для отслеживания изменений.""" + if instance.pk: + try: + Request = sender + old_instance = Request.objects.get(pk=instance.pk) + instance._old_status = old_instance.status + except sender.DoesNotExist: + instance._old_status = None + + +@receiver(post_save, sender='requests_app.Request') +def create_action_on_request_approval(sender, instance, created, **kwargs): + """ + Автоматически создает EmployeeAction при одобрении заявок. + + Две стратегии: + 1. НЕМЕДЛЕННО (transfer, dismissal) - сразу при одобрении + 2. ОТЛОЖЕННО (vacation, sick_leave) - через Celery в date_from + """ + # Пропускаем если это новая заявка + if created: + return + + # Получаем enum из requests_app + try: + from requests_app.enums import RequestStatus + except ImportError: + logger.error("Cannot import RequestStatus from requests_app") + return + + # Проверяем изменение статуса + old_status = getattr(instance, '_old_status', None) + if old_status == RequestStatus.APPROVED: + return # Уже была одобрена + + if instance.status != RequestStatus.APPROVED: + return # Не одобрение + + # === СТРАТЕГИЯ 1: Немедленное создание === + if instance.type in IMMEDIATE_ACTION_MAPPING: + _create_immediate_action(instance) + + # === СТРАТЕГИЯ 2: Отложенное создание через Celery === + elif instance.type in SCHEDULED_ACTION_MAPPING: + _schedule_delayed_action(instance) + + else: + logger.debug( + f"Request #{instance.id} approved but type '{instance.type}' " + f"doesn't create EmployeeAction" + ) + + +def _create_immediate_action(request): + """Создать событие немедленно (для увольнения, перевода).""" + action_type = IMMEDIATE_ACTION_MAPPING.get(request.type) + if not action_type: + return + + # Проверяем дубли + if EmployeeAction.objects.filter( + extra__request_id=request.id, + action=action_type + ).exists(): + logger.warning( + f"EmployeeAction already exists for Request #{request.id}" + ) + return + + # Создаем событие + action_date = request.date_from or request.decided_at or timezone.now() + action_comment = f"Заявление #{request.id}" + if request.comment: + action_comment += f": {request.comment[:200]}" + + try: + action = EmployeeAction.objects.create( + employee=request.employee, + action=action_type, + date=action_date, + comment=action_comment, + extra={ + 'request_id': request.id, + 'approved_by': ( + request.approver.id if request.approver else None + ), + 'immediate': True + } + ) + + # Применяем эффекты (деактивация, LDAP sync) + _apply_action_effects(action) + + logger.info( + f"Created immediate EmployeeAction #{action.id} ({action_type}) " + f"from Request #{request.id}" + ) + + except Exception as e: + logger.error( + f"Failed to create EmployeeAction for Request #{request.id}: {e}", + exc_info=True + ) + + +def _schedule_delayed_action(request): + """Запланировать создание события через Celery.""" + try: + from requests_app.tasks import create_scheduled_action + except ImportError: + logger.error("Cannot import create_scheduled_action task") + return + + if not request.date_from: + logger.warning( + f"Request #{request.id} type '{request.type}' needs date_from " + f"for scheduling" + ) + return + + # Если date_from уже прошла или сегодня - создаем сразу + today = timezone.now().date() + if request.date_from <= today: + logger.info( + f"Request #{request.id} date_from is today or past, " + f"creating action immediately" + ) + # Вызываем task синхронно + create_scheduled_action.apply(args=[request.id]) + return + + # Иначе планируем на date_from в 00:00 + eta = timezone.make_aware( + datetime.combine(request.date_from, datetime.min.time()) + ) + + try: + task = create_scheduled_action.apply_async( + args=[request.id], + eta=eta + ) + logger.info( + f"Scheduled EmployeeAction creation for Request #{request.id} " + f"at {eta} (task_id={task.id})" + ) + except Exception as e: + logger.error( + f"Failed to schedule action for Request #{request.id}: {e}", + exc_info=True + ) + + +def _apply_action_effects(action): + """Применяет эффекты кадрового события (как в EmployeeActionViewSet).""" + try: + from api.v1.employees.views.actions import EmployeeActionViewSet + viewset = EmployeeActionViewSet() + viewset._apply_effects(action) + except Exception as e: + logger.error( + f"Failed to apply effects for EmployeeAction #{action.id}: {e}", + exc_info=True + ) diff --git a/backend/eusrr_backend/celery.py b/backend/eusrr_backend/celery.py index a47f8c4c..36fc04d3 100644 --- a/backend/eusrr_backend/celery.py +++ b/backend/eusrr_backend/celery.py @@ -3,6 +3,7 @@ """ import os from celery import Celery +from celery.schedules import crontab # Устанавливаем настройки Django по умолчанию os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings') @@ -17,6 +18,14 @@ app.config_from_object('django.conf:settings', namespace='CELERY') # Автоматически находит tasks.py в каждом приложении app.autodiscover_tasks() +# Periodic tasks (Celery Beat) +app.conf.beat_schedule = { + 'cleanup-missed-returns': { + 'task': 'requests_app.tasks.cleanup_missed_returns', + 'schedule': crontab(hour=0, minute=5), # Каждый день в 00:05 + }, +} + @app.task(bind=True, ignore_result=True) def debug_task(self): diff --git a/backend/requests_app/__init__.py b/backend/requests_app/__init__.py index e69de29b..f6f84776 100644 --- a/backend/requests_app/__init__.py +++ b/backend/requests_app/__init__.py @@ -0,0 +1 @@ +default_app_config = 'requests_app.apps.RequestsAppConfig' diff --git a/backend/requests_app/apps.py b/backend/requests_app/apps.py index cfc86788..597afc82 100644 --- a/backend/requests_app/apps.py +++ b/backend/requests_app/apps.py @@ -8,4 +8,5 @@ class RequestsAppConfig(AppConfig): def ready(self): # Подключаем модуль уведомлений (signals регистрируются автоматически) import requests_app.notifications.signals # noqa + # import requests_app.signals # MOVED: Автоматическое создание EmployeeAction теперь в employees/signals.py import requests_app.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/requests_app/management/__init__.py b/backend/requests_app/management/__init__.py new file mode 100644 index 00000000..65cb83ac --- /dev/null +++ b/backend/requests_app/management/__init__.py @@ -0,0 +1 @@ +# Management commands package diff --git a/backend/requests_app/management/commands/__init__.py b/backend/requests_app/management/commands/__init__.py new file mode 100644 index 00000000..2c1c7c15 --- /dev/null +++ b/backend/requests_app/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/backend/requests_app/management/commands/migrate_requests_to_actions.py b/backend/requests_app/management/commands/migrate_requests_to_actions.py new file mode 100644 index 00000000..6dcc0897 --- /dev/null +++ b/backend/requests_app/management/commands/migrate_requests_to_actions.py @@ -0,0 +1,183 @@ +""" +Management команда для миграции существующих заявок в кадровые события. + +Использование: + python manage.py migrate_requests_to_actions [--dry-run] +""" +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from requests_app.models import Request +from requests_app.enums import RequestStatus +from employees.models import EmployeeAction +from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING + + +class Command(BaseCommand): + help = 'Создает EmployeeAction для всех одобренных заявок, у которых их еще нет' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Показать что будет создано, но не создавать', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + + if dry_run: + self.stdout.write(self.style.WARNING('=== DRY RUN MODE ===')) + + # Объединяем все маппинги + all_mappings = {**IMMEDIATE_ACTION_MAPPING, **SCHEDULED_ACTION_MAPPING} + + # Находим все одобренные заявки с типами, которые требуют EmployeeAction + approved_requests = Request.objects.filter( + status=RequestStatus.APPROVED, + type__in=all_mappings.keys() + ).select_related('employee', 'approver').order_by('created_at') + + total_requests = approved_requests.count() + self.stdout.write( + f'Найдено {total_requests} одобренных заявок типов: {list(all_mappings.keys())}' + ) + + created_count = 0 + skipped_count = 0 + errors = [] + + for request in approved_requests: + action_type = all_mappings.get(request.type) + if not action_type: + continue + + # Проверяем, существует ли уже событие для этой заявки + existing = EmployeeAction.objects.filter( + extra__request_id=request.id, + action=action_type + ).exists() + + if existing: + skipped_count += 1 + self.stdout.write( + self.style.WARNING( + f' SKIP: Request #{request.id} ({request.type}) - ' + f'EmployeeAction уже существует' + ) + ) + continue + + # Определяем дату события + action_date = request.date_from or request.decided_at or request.created_at or timezone.now() + + # Формируем комментарий + action_comment = f"Заявление #{request.id}" + if request.comment: + action_comment += f": {request.comment[:200]}" + + # Готовим extra данные + extra_data = { + 'request_id': request.id, + 'approved_by': request.approver.id if request.approver else None, + 'migrated': True, # Помечаем что создано миграцией + 'migration_date': timezone.now().isoformat() + } + + if dry_run: + # action_date может быть date или datetime + date_str = action_date.date() if hasattr(action_date, 'date') else action_date + self.stdout.write( + self.style.SUCCESS( + f' WOULD CREATE: Request #{request.id} ({request.type}) → ' + f'EmployeeAction ({action_type}) for {request.employee} ' + f'on {date_str}' + ) + ) + created_count += 1 + else: + try: + with transaction.atomic(): + action = EmployeeAction.objects.create( + employee=request.employee, + action=action_type, + date=action_date, + comment=action_comment, + extra=extra_data + ) + + # Применяем эффекты (деактивация, LDAP sync) + self._apply_effects(action) + + created_count += 1 + self.stdout.write( + self.style.SUCCESS( + f' ✓ Created: EmployeeAction #{action.id} ({action_type}) ' + f'from Request #{request.id} for {request.employee}' + ) + ) + + except Exception as e: + errors.append((request.id, str(e))) + self.stdout.write( + self.style.ERROR( + f' ✗ Failed: Request #{request.id} - {e}' + ) + ) + + # Итоговая статистика + self.stdout.write('\n' + '='*70) + if dry_run: + self.stdout.write(self.style.WARNING('DRY RUN - изменения не применены')) + + self.stdout.write( + self.style.SUCCESS( + f'Обработано заявок: {total_requests}' + ) + ) + + if created_count: + verb = 'Будет создано' if dry_run else 'Создано' + self.stdout.write( + self.style.SUCCESS( + f'{verb} событий: {created_count}' + ) + ) + + if skipped_count: + self.stdout.write( + self.style.WARNING( + f'Пропущено (уже существуют): {skipped_count}' + ) + ) + + if errors: + self.stdout.write( + self.style.ERROR( + f'\nОшибки ({len(errors)}):' + ) + ) + for req_id, error in errors: + self.stdout.write(f' Request #{req_id}: {error}') + + if not dry_run and created_count > 0: + self.stdout.write( + self.style.SUCCESS( + '\n✓ Миграция завершена успешно!' + ) + ) + + def _apply_effects(self, action): + """Применяет эффекты кадрового события (как в EmployeeActionViewSet).""" + try: + from api.v1.employees.views.actions import EmployeeActionViewSet + viewset = EmployeeActionViewSet() + viewset._apply_effects(action) + except Exception as e: + # Не критично если эффекты не применились + self.stdout.write( + self.style.WARNING( + f' Warning: Could not apply effects for action #{action.id}: {e}' + ) + ) diff --git a/backend/requests_app/tasks.py b/backend/requests_app/tasks.py new file mode 100644 index 00000000..770d18b4 --- /dev/null +++ b/backend/requests_app/tasks.py @@ -0,0 +1,270 @@ +""" +Celery tasks для автоматизации кадровых событий из заявок. +""" +import logging +from datetime import date, timedelta + +from celery import shared_task +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3) +def create_scheduled_action(self, request_id): + """ + Создать кадровое событие для заявки по расписанию. + + Вызывается в date_from для vacation/sick_leave. + + Args: + request_id: ID заявки + """ + from .models import Request + from .enums import RequestStatus + from employees.models import EmployeeAction + + try: + request = Request.objects.get(id=request_id) + + # Проверяем, что заявка все еще одобрена + if request.status != RequestStatus.APPROVED: + logger.info( + f"Request #{request_id} is no longer approved " + f"(status={request.status}), skipping action creation" + ) + return + + # Определяем тип события + action_mapping = { + 'vacation': 'on_leave', + 'sick_leave': 'on_sick_leave', + } + + action_type = action_mapping.get(request.type) + if not action_type: + logger.warning( + f"Request #{request_id} has type '{request.type}' " + f"which doesn't map to scheduled action" + ) + return + + # Проверяем, что событие еще не создано + if EmployeeAction.objects.filter( + extra__request_id=request_id, + action=action_type + ).exists(): + logger.info( + f"Action already exists for Request #{request_id}, skipping" + ) + return + + # Создаем событие + action_comment = f"Заявление #{request.id}" + if request.comment: + action_comment += f": {request.comment[:200]}" + + action = EmployeeAction.objects.create( + employee=request.employee, + action=action_type, + date=timezone.now(), + comment=action_comment, + extra={ + 'request_id': request.id, + 'approved_by': ( + request.approver.id if request.approver else None + ), + 'scheduled': True + } + ) + + logger.info( + f"Created scheduled EmployeeAction #{action.id} ({action_type}) " + f"for Request #{request_id}" + ) + + # Планируем автовозврат на date_to + 1 день + if request.date_to: + schedule_auto_return.apply_async( + args=[request_id], + eta=timezone.make_aware( + timezone.datetime.combine( + request.date_to + timedelta(days=1), + timezone.datetime.min.time() + ) + ) + ) + + return f"Created action {action.id}" + + except Request.DoesNotExist: + logger.error(f"Request #{request_id} not found") + raise + except Exception as e: + logger.error( + f"Failed to create scheduled action for Request #{request_id}: {e}", + exc_info=True + ) + # Retry через 5 минут + raise self.retry(exc=e, countdown=300) + + +@shared_task(bind=True, max_retries=3) +def schedule_auto_return(self, request_id): + """ + Автоматически создать событие возврата из отпуска/больничного. + + Вызывается в date_to + 1 день. + + Args: + request_id: ID заявки + """ + from .models import Request + from .enums import RequestStatus + from employees.models import EmployeeAction + + try: + request = Request.objects.get(id=request_id) + + # Проверяем, что заявка все еще одобрена + if request.status != RequestStatus.APPROVED: + logger.info( + f"Request #{request_id} is no longer approved, " + f"skipping auto-return" + ) + return + + # Определяем тип возврата + return_mapping = { + 'vacation': 'returned_from_leave', + 'sick_leave': 'returned_from_leave', + } + + return_action = return_mapping.get(request.type) + if not return_action: + logger.warning( + f"Request #{request_id} type '{request.type}' " + f"doesn't have return action" + ) + return + + # Проверяем, что событие возврата еще не создано + if EmployeeAction.objects.filter( + extra__request_id=request_id, + action=return_action + ).exists(): + logger.info( + f"Return action already exists for Request #{request_id}" + ) + return + + # Создаем событие возврата + return_comment = ( + f"Автоматически: окончание " + f"{request.get_type_display().lower()} " + f"(заявка #{request.id})" + ) + + action = EmployeeAction.objects.create( + employee=request.employee, + action=return_action, + date=timezone.now(), + comment=return_comment, + extra={ + 'request_id': request.id, + 'auto_return': True + } + ) + + logger.info( + f"Created auto-return EmployeeAction #{action.id} " + f"({return_action}) for Request #{request_id}" + ) + + return f"Created return action {action.id}" + + except Request.DoesNotExist: + logger.error(f"Request #{request_id} not found for auto-return") + raise + except Exception as e: + logger.error( + f"Failed to create auto-return for Request #{request_id}: {e}", + exc_info=True + ) + raise self.retry(exc=e, countdown=300) + + +@shared_task +def cleanup_missed_returns(): + """ + Periodic task: проверяет заявки, для которых date_to прошел, + но событие возврата не создано. + + Запускается ежедневно в 00:05. + """ + from .models import Request + from .enums import RequestStatus + from employees.models import EmployeeAction + + logger.info("Running cleanup_missed_returns task") + + today = date.today() + yesterday = today - timedelta(days=1) + + # Ищем одобренные заявки, которые закончились вчера или раньше + ended_requests = Request.objects.filter( + status=RequestStatus.APPROVED, + type__in=['vacation', 'sick_leave'], + date_to__lt=today, + date_to__gte=yesterday - timedelta(days=7) # За последнюю неделю + ) + + created_count = 0 + + for req in ended_requests: + # Определяем тип возврата + return_action = { + 'vacation': 'returned_from_leave', + 'sick_leave': 'returned_from_leave', + }.get(req.type) + + if not return_action: + continue + + # Проверяем, что события возврата нет + if EmployeeAction.objects.filter( + extra__request_id=req.id, + action=return_action + ).exists(): + continue + + # Создаем пропущенное событие возврата + try: + action = EmployeeAction.objects.create( + employee=req.employee, + action=return_action, + date=req.date_to + timedelta(days=1), + comment=( + f"Автоматически восстановлено: окончание " + f"{req.get_type_display().lower()} (заявка #{req.id})" + ), + extra={ + 'request_id': req.id, + 'auto_return': True, + 'cleanup': True + } + ) + created_count += 1 + logger.info( + f"Cleanup: created return action #{action.id} " + f"for Request #{req.id}" + ) + except Exception as e: + logger.error( + f"Cleanup: failed to create return for Request #{req.id}: {e}" + ) + + logger.info( + f"cleanup_missed_returns completed: {created_count} actions created" + ) + return created_count diff --git a/backend/requests_app/tests/conftest.py b/backend/requests_app/tests/conftest.py new file mode 100644 index 00000000..00229d56 --- /dev/null +++ b/backend/requests_app/tests/conftest.py @@ -0,0 +1,41 @@ +""" +Conftest для тестов requests_app - импортирует общие фикстуры. +""" +import pytest +from django.conf import settings + + +@pytest.fixture +def user_factory(): + """Фабрика для создания пользователей.""" + from employees.models import Employee + + def _make( + email: str = None, + *, + first_name: str = "Test", + last_name: str = "User", + active: bool = True, + **extra + ): + import uuid + if not email: + email = f"test+{uuid.uuid4().hex[:8]}@example.com" + + # Генерируем уникальные phone_number и username + unique_id = uuid.uuid4().hex[:8] + if 'phone_number' not in extra: + extra['phone_number'] = f"+7900{unique_id}" + if 'username' not in extra: + extra['username'] = f"user_{unique_id}" + + user = Employee.objects.create( + email=email, + first_name=first_name, + last_name=last_name, + is_active=active, + **extra + ) + return user + + return _make diff --git a/backend/requests_app/tests/test_signals.py b/backend/requests_app/tests/test_signals.py new file mode 100644 index 00000000..ae475ec8 --- /dev/null +++ b/backend/requests_app/tests/test_signals.py @@ -0,0 +1,298 @@ +""" +Тесты для автоматического создания EmployeeAction из заявок (signals). +""" +import pytest +from datetime import date, timedelta +from unittest.mock import patch, MagicMock +from django.utils import timezone + +from requests_app.models import Request +from requests_app.enums import RequestStatus +from employees.models import EmployeeAction +from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING + + +@pytest.mark.django_db +class TestRequestSignals: + """Тесты сигналов создания EmployeeAction из Request.""" + + def test_immediate_action_dismissal(self, user_factory): + """Увольнение создаёт EmployeeAction немедленно при одобрении.""" + employee = user_factory() + approver = user_factory() + + # Создаём заявку на увольнение + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + date_from=date.today(), + comment='Увольнение по собственному желанию' + ) + + # Проверяем что события ещё нет + assert not EmployeeAction.objects.filter( + extra__request_id=request.id + ).exists() + + # Одобряем заявку + request.status = RequestStatus.APPROVED + request.approver = approver + request.decided_at = timezone.now() + request.save() + + # Проверяем что событие создалось + action = EmployeeAction.objects.get( + employee=employee, + action='dismissed', + extra__request_id=request.id + ) + + assert action.action == 'dismissed' + assert action.extra['request_id'] == request.id + assert action.extra['approved_by'] == approver.id + assert action.extra['immediate'] is True + assert 'Заявление #' in action.comment + + def test_immediate_action_transfer(self, user_factory): + """Перевод создаёт EmployeeAction немедленно при одобрении.""" + employee = user_factory() + approver = user_factory() + + request = Request.objects.create( + employee=employee, + type='transfer', + status=RequestStatus.PENDING, + date_from=date.today(), + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # Проверяем событие + action = EmployeeAction.objects.get( + employee=employee, + action='transferred', + extra__request_id=request.id + ) + + assert action.action == 'transferred' + assert action.extra['immediate'] is True + + @patch('employees.signals._schedule_delayed_action') + def test_scheduled_action_vacation(self, mock_schedule, user_factory): + """Отпуск запускает планирование через Celery.""" + employee = user_factory() + approver = user_factory() + future_date = date.today() + timedelta(days=7) + + request = Request.objects.create( + employee=employee, + type='vacation', + status=RequestStatus.PENDING, + date_from=future_date, + date_to=future_date + timedelta(days=10), + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # Проверяем что вызвалась функция планирования + mock_schedule.assert_called_once() + called_request = mock_schedule.call_args[0][0] + assert called_request.id == request.id + + @patch('employees.signals._schedule_delayed_action') + def test_scheduled_action_sick_leave(self, mock_schedule, user_factory): + """Больничный запускает планирование через Celery.""" + employee = user_factory() + approver = user_factory() + future_date = date.today() + timedelta(days=3) + + request = Request.objects.create( + employee=employee, + type='sick_leave', + status=RequestStatus.PENDING, + date_from=future_date, + ) + + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + mock_schedule.assert_called_once() + + def test_no_duplicate_actions(self, user_factory): + """Повторное сохранение одобренной заявки не создаёт дубликаты.""" + employee = user_factory() + approver = user_factory() + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + date_from=date.today(), + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # Первое событие создалось + assert EmployeeAction.objects.filter( + employee=employee, + action='dismissed', + extra__request_id=request.id + ).count() == 1 + + # Меняем комментарий и сохраняем снова + request.comment = 'Обновлённый комментарий' + request.save() + + # Дубликат не создался + assert EmployeeAction.objects.filter( + employee=employee, + action='dismissed', + extra__request_id=request.id + ).count() == 1 + + def test_signal_not_fired_on_creation(self, user_factory): + """Сигнал не срабатывает при создании (только при одобрении).""" + employee = user_factory() + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + ) + + # События нет + assert not EmployeeAction.objects.filter( + extra__request_id=request.id + ).exists() + + def test_signal_not_fired_on_rejection(self, user_factory): + """Сигнал не срабатывает при отклонении.""" + employee = user_factory() + approver = user_factory() + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + ) + + # Отклоняем + request.status = RequestStatus.REJECTED + request.approver = approver + request.save() + + # События нет + assert not EmployeeAction.objects.filter( + extra__request_id=request.id + ).exists() + + def test_unknown_request_type_no_action(self, user_factory): + """Неизвестный тип заявки не создаёт события.""" + employee = user_factory() + approver = user_factory() + + # Используем тип, который не в маппингах + request = Request.objects.create( + employee=employee, + type='other', # Не в IMMEDIATE/SCHEDULED маппингах + status=RequestStatus.PENDING, + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # События нет + assert not EmployeeAction.objects.filter( + employee=employee, + extra__request_id=request.id + ).exists() + + def test_action_date_from_request_date_from(self, user_factory): + """Дата события берётся из date_from заявки.""" + employee = user_factory() + approver = user_factory() + specific_date = date(2026, 6, 15) + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + date_from=specific_date, + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + action = EmployeeAction.objects.get( + employee=employee, + action='dismissed', + extra__request_id=request.id + ) + + # Проверяем дату (может быть datetime, поэтому берём .date()) + action_date = action.date.date() if hasattr(action.date, 'date') else action.date + assert action_date == specific_date + + @patch('employees.signals._apply_action_effects') + def test_effects_applied_for_immediate_actions(self, mock_apply, user_factory): + """Эффекты применяются для немедленных действий.""" + employee = user_factory() + approver = user_factory() + + request = Request.objects.create( + employee=employee, + type='dismissal', + status=RequestStatus.PENDING, + ) + + # Одобряем + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + # Проверяем что _apply_action_effects была вызвана + mock_apply.assert_called_once() + created_action = mock_apply.call_args[0][0] + assert created_action.action == 'dismissed' + + +@pytest.mark.django_db +class TestRequestMappings: + """Тесты корректности маппингов типов заявок.""" + + def test_immediate_mapping_complete(self): + """Проверка что все немедленные маппинги правильные.""" + assert IMMEDIATE_ACTION_MAPPING == { + 'transfer': 'transferred', + 'dismissal': 'dismissed', + } + + def test_scheduled_mapping_complete(self): + """Проверка что все отложенные маппинги правильные.""" + assert SCHEDULED_ACTION_MAPPING == { + 'vacation': 'on_leave', + 'sick_leave': 'on_sick_leave', + } + + def test_no_overlap_in_mappings(self): + """Проверка что маппинги не пересекаются.""" + immediate_keys = set(IMMEDIATE_ACTION_MAPPING.keys()) + scheduled_keys = set(SCHEDULED_ACTION_MAPPING.keys()) + + assert immediate_keys.isdisjoint(scheduled_keys), \ + "Request types должны быть либо immediate, либо scheduled, но не оба" diff --git a/docs/completed/REQUESTS_CELERY_AUTOMATION.md b/docs/completed/REQUESTS_CELERY_AUTOMATION.md new file mode 100644 index 00000000..37f6a522 --- /dev/null +++ b/docs/completed/REQUESTS_CELERY_AUTOMATION.md @@ -0,0 +1,318 @@ +# Автоматизация кадровых событий через Celery + +**Дата**: 17 марта 2026 г. +**Статус**: ✅ Реализовано + +## Что сделано + +Реализована **полная автоматизация** создания кадровых событий из заявок с использованием **Celery + Redis**. + +## Архитектура + +### 3 стратегии обработки заявок: + +``` +┌─────────────────────────────────────────────┐ +│ 1. НЕМЕДЛЕННЫЕ (dismissal, transfer) │ +│ ↓ approve() → Signal → EmployeeAction │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ 2. ОТЛОЖЕННЫЕ (vacation, sick_leave) │ +│ ↓ approve() → Signal → Celery Task │ +│ ↓ date_from → EmployeeAction │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ 3. АВТОВОЗВРАТ (vacation, sick_leave) │ +│ ↓ date_to + 1 день → Celery Task │ +│ ↓ EmployeeAction (returned_from_leave) │ +└─────────────────────────────────────────────┘ +``` + +--- + +## Файлы + +### 1. `requests_app/tasks.py` - Celery задачи + +**Задачи**: +- `create_scheduled_action()` - создает событие в date_from +- `schedule_auto_return()` - создает событие возврата в date_to + 1 +- `cleanup_missed_returns()` - periodic task для восстановления пропущенных + +### 2. `requests_app/signals.py` - обработчик одобрения + +**Логика**: +- Отслеживает переход в APPROVED +- Немедленные типы → создает сразу +- Отложенные типы → планирует через Celery + +### 3. `eusrr_backend/celery.py` - Celery beat schedule + +**Periodic tasks**: +- `cleanup-missed-returns` - каждый день в 00:05 + +--- + +## Таблица автоматизации + +| Заявка | Событие начала | Когда | Событие возврата | Когда | +|--------|----------------|-------|------------------|-------| +| **dismissal** | `dismissed` | ✅ Сразу при approve | - | - | +| **transfer** | `transferred` | ✅ Сразу при approve | - | - | +| **vacation** | `on_leave` | 🕐 В `date_from` | `returned_from_leave` | 🕐 `date_to` + 1 день | +| **sick_leave** | `on_sick_leave` | 🕐 В `date_from` | `returned_from_leave` | 🕐 `date_to` + 1 день | +| **day_off** | - | ❌ Не создается | - | - | +| **other** | - | ❌ Не создается | - | - | + +--- + +## Примеры работы + +### Пример 1: Увольнение (немедленное) + +```python +# 17 марта - создаем заявку +request = Request.objects.create( + employee=user, + type='dismissal', + date_from='2026-04-01' +) + +# 17 марта - HR одобряет +request.approve(hr_manager) + +# ✅ СРАЗУ создано EmployeeAction(action='dismissed') +# ✅ employee.is_active = False +# ✅ LDAP sync выполнен +``` + +### Пример 2: Отпуск (отложенное) + +```python +# 17 марта - создаем заявку на отпуск 1-14 апреля +request = Request.objects.create( + employee=user, + type='vacation', + date_from='2026-04-01', + date_to='2026-04-14' +) + +# 17 марта - HR одобряет +request.approve(hr_manager) +# ✅ Celery task запланирован на 01.04.2026 00:00 + +# 1 апреля 00:00 - Celery автоматически: +# ✅ Создает EmployeeAction(action='on_leave') +# ✅ Планирует автовозврат на 15.04.2026 00:00 + +# 15 апреля 00:00 - Celery автоматически: +# ✅ Создает EmployeeAction(action='returned_from_leave') +``` + +### Пример 3: Отпуск начинается сегодня + +```python +# 17 марта - создаем заявку с date_from=сегодня +request = Request.objects.create( + employee=user, + type='vacation', + date_from='2026-03-17', # Сегодня! + date_to='2026-03-24' +) + +# 17 марта - HR одобряет +request.approve(hr_manager) +# ✅ Событие создается СРАЗУ (не через Celery) +# ✅ Автовозврат запланирован на 25.03.2026 +``` + +--- + +## Защита от ошибок + +### 1. **Idempotency** (защита от дублей) +```python +# Проверка перед созданием +if EmployeeAction.objects.filter( + extra__request_id=request_id, + action=action_type +).exists(): + return # Уже создано +``` + +### 2. **Retry логика** +```python +@shared_task(bind=True, max_retries=3) +def create_scheduled_action(self, request_id): + try: + # ... создание + except Exception as e: + # Retry через 5 минут + raise self.retry(exc=e, countdown=300) +``` + +### 3. **Cleanup task** (восстановление пропущенных) +```python +# Каждый день в 00:05 проверяет: +# - Заявки, которые закончились, но нет события возврата +# - Создает пропущенные события +``` + +### 4. **Отмена заявки** +```python +# Если заявку отменили до date_from: +# - Celery task выполнится +# - Проверит статус: if status != APPROVED: return +# - Событие НЕ будет создано +``` + +--- + +## Метаданные в extra + +Каждое созданное событие содержит: + +```python +# Немедленное событие +extra = { + 'request_id': 123, + 'approved_by': 5, + 'immediate': True +} + +# Отложенное событие +extra = { + 'request_id': 123, + 'approved_by': 5, + 'scheduled': True +} + +# Автовозврат +extra = { + 'request_id': 123, + 'auto_return': True +} + +# Восстановленное cleanup'ом +extra = { + 'request_id': 123, + 'auto_return': True, + 'cleanup': True +} +``` + +--- + +## Запуск + +### Celery Worker +```bash +cd backend +celery -A eusrr_backend worker -l info +``` + +### Celery Beat (для periodic tasks) +```bash +celery -A eusrr_backend beat -l info +``` + +### Одной командой (для разработки) +```bash +celery -A eusrr_backend worker -B -l info +``` + +--- + +## Мониторинг + +### Flower (Celery monitoring) +```bash +celery -A eusrr_backend flower +# http://localhost:5555 +``` + +### Проверка задач +```python +from celery import current_app + +# Посмотреть запланированные задачи +current_app.control.inspect().scheduled() + +# Посмотреть активные задачи +current_app.control.inspect().active() +``` + +--- + +## Расширение + +### Добавить новый тип (например, декрет) + +**1. Обновить маппинг**: +```python +# requests_app/signals.py +SCHEDULED_ACTION_MAPPING = { + "vacation": "on_leave", + "sick_leave": "on_sick_leave", + "maternity": "on_maternity", # ← Добавить +} +``` + +**2. Обновить tasks.py**: +```python +# В create_scheduled_action +action_mapping = { + 'vacation': 'on_leave', + 'sick_leave': 'on_sick_leave', + 'maternity': 'on_maternity', # ← Добавить +} + +# В schedule_auto_return +return_mapping = { + 'vacation': 'returned_from_leave', + 'sick_leave': 'returned_from_leave', + 'maternity': 'returned_from_maternity', # ← Добавить +} +``` + +**3. Добавить тип в RequestType**: +```python +# requests_app/enums.py +class RequestType(TextChoices): + # ... + MATERNITY = "maternity", "Декрет" +``` + +**Всё!** Автоматизация заработает для нового типа. + +--- + +## Логирование + +Все действия логируются: + +``` +INFO: Scheduled EmployeeAction creation for Request #123 at 2026-04-01 00:00:00 +INFO: Created scheduled EmployeeAction #456 (on_leave) for Request #123 +INFO: Created auto-return EmployeeAction #457 (returned_from_leave) for Request #123 +INFO: cleanup_missed_returns completed: 2 actions created +``` + +--- + +## Преимущества решения + +✅ **Автоматизация** - все работает без ручного вмешательства +✅ **Гибкость** - можно отменить заявку до date_from +✅ **Надежность** - retry + cleanup task +✅ **Масштабируемость** - Celery + Redis +✅ **Audittrail** - все события с метаданными +✅ **Расширяемость** - легко добавить новые типы +✅ **Event-driven** - декаплинг через сигналы + +--- + +**Автор**: GitHub Copilot +**Дата**: 17 марта 2026 г. diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 2876c711..25e580cc 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); + const [creatingChatFor, setCreatingChatFor] = useState(null); const loaderRef = useRef(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 ( - -
+
+
{employee.avatar ? ( {fullName} @@ -126,25 +174,29 @@ export default function EmployeesPage() { initials )}
- {isOnline ? ( - - ) : null} -
-
-

{fullName}

-

{position}

-
- - {statusText} +
+

{fullName}

+

{position}

{employee.email && ( - <> - • - {employee.email} - +

{employee.email}

)}
-
- + + {!isCurrentUser && ( + + )} +
); })} diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 91f6d711..5901b82e 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1188,12 +1188,8 @@ export default function MessageDialogPage() { )}
-
+

{getChatTitle(chat, user?.id)}

- {/* Индикатор WebSocket соединения */} - {isConnected && ( - - )}

{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}

@@ -1302,9 +1298,6 @@ export default function MessageDialogPage() { getMessageInitials(message) )}
- {message.author?.is_active ? ( - - ) : null} ) : null} diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index a77fd735..7b892d24 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -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 === "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() { {getChatTypeIcon(chat)} - {/* Онлайн-статус */} - {isChatOnline(chat, user?.id) ? ( - - ) : null} {/* Счетчик непрочитанных */} {(chat.unread_count ?? 0) > 0 ? ( diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 34e8a4ad..6c6b8b16 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 )} - {isAuthorOnline ? ( - - ) : null}

{authorName}

diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index dec9755e..50bfdac6 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -203,7 +203,6 @@ export default function ProfilePage() { ) : (
{initials}
)} - {user.is_active ? : null}
(); + const router = useRouter(); + const { user: currentUser } = useUser(); const userId = Number(params?.id); const [person, setPerson] = useState(null); const [avatarFailed, setAvatarFailed] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [creatingChat, setCreatingChat] = useState(false); + const [copySuccess, setCopySuccess] = useState(null); + + // Состояния для модального окна редактирования + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isActionModalOpen, setIsActionModalOpen] = useState(false); + const [editFirstName, setEditFirstName] = useState(""); + const [editLastName, setEditLastName] = useState(""); + const [editPatronymic, setEditPatronymic] = useState(""); + const [editEmail, setEditEmail] = useState(""); + const [editPhone, setEditPhone] = useState(""); + const [editTelegram, setEditTelegram] = useState(""); + const [editWhatsApp, setEditWhatsApp] = useState(""); + const [editWeChat, setEditWeChat] = useState(""); + const [editAvatarFile, setEditAvatarFile] = useState(null); + const [editAvatarPreview, setEditAvatarPreview] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + + // Состояния для модального окна кадровых событий + const [actionType, setActionType] = useState(""); + const [actionDate, setActionDate] = useState(new Date().toISOString().split('T')[0]); + const [actionComment, setActionComment] = useState(""); + const [editingActionId, setEditingActionId] = useState(null); useEffect(() => { async function loadUser() { @@ -48,70 +75,977 @@ export default function UserDetailPage() { return `${person.last_name || ""} ${person.first_name || ""} ${person.patronymic || ""}`.trim() || "Пользователь"; }, [person]); + // Последний статус сотрудника + const latestAction = useMemo(() => { + if (!person?.actions || person.actions.length === 0) return null; + return person.actions.reduce((latest, action) => + new Date(action.date) > new Date(latest.date) ? action : latest + ); + }, [person?.actions]); + + // Отсортированная история событий (от новых к старым) + const sortedActions = useMemo(() => { + if (!person?.actions || person.actions.length === 0) return []; + return [...person.actions].sort((a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ); + }, [person?.actions]); + const avatarUrl = resolveMediaUrl(person?.avatar); const initials = `${person?.last_name?.[0] || ""}${person?.first_name?.[0] || ""}` || "П"; + // Копирование в буфер обмена + const copyToClipboard = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setCopySuccess(label); + setTimeout(() => setCopySuccess(null), 2000); + } catch (err) { + console.error("Ошибка копирования:", err); + } + }; + + // Создание чата с пользователем + const handleStartChat = async () => { + if (!person || !currentUser || creatingChat) return; + + try { + setCreatingChat(true); + + // Сначала ищем существующий директ-чат с этим пользователем + 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(person.id); + }); + + if (existingChat) { + // Если чат уже существует - переходим в него + router.push(`/messages/${existingChat.id}`); + } else { + // Если не существует - создаем новый + const chat = await apiClient.createChat({ + type: 'private', + name: 'Диалог', + participants: [person.id] + }); + router.push(`/messages/${chat.id}`); + } + } catch (err: any) { + console.error("Ошибка создания чата:", err); + alert("Не удалось открыть чат"); + } finally { + setCreatingChat(false); + } + }; + + // Форматирование телефона для ссылок + const formatPhoneForLink = (phone: string) => { + return phone.replace(/[^0-9+]/g, ''); + }; + + // Вычисление стажа + const getWorkDuration = (dateJoined?: string) => { + if (!dateJoined) return null; + const start = new Date(dateJoined); + const now = new Date(); + const diffMs = now.getTime() - start.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + const years = Math.floor(diffDays / 365); + const months = Math.floor((diffDays % 365) / 30); + + const parts = []; + if (years > 0) parts.push(`${years} ${years === 1 ? 'год' : years < 5 ? 'года' : 'лет'}`); + if (months > 0) parts.push(`${months} ${months === 1 ? 'месяц' : months < 5 ? 'месяца' : 'месяцев'}`); + + return parts.join(' ') || 'меньше месяца'; + }; + + // Форматирование даты рождения (без года) + const formatBirthday = (birthDate?: string) => { + if (!birthDate) return null; + const date = new Date(birthDate); + return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }); + }; + + // Закрытие модального окна действий + const handleCloseActionModal = () => { + setIsActionModalOpen(false); + setActionType(""); + setActionDate(new Date().toISOString().split('T')[0]); + setActionComment(""); + setEditingActionId(null); + }; + + // Открытие модального окна действий для создания + const handleOpenActionModal = () => { + setEditingActionId(null); + setActionType(""); + setActionDate(new Date().toISOString().split('T')[0]); + setActionComment(""); + setIsActionModalOpen(true); + }; + + // Открытие модального окна для редактирования события + const handleEditAction = (action: any) => { + setEditingActionId(action.id); + setActionType(action.action); + setActionDate(action.date); + setActionComment(action.comment || ""); + setIsActionModalOpen(true); + }; + + // Удаление кадрового события + const handleDeleteAction = async (actionId: number) => { + if (!confirm('Вы уверены, что хотите удалить это событие?')) return; + + setActionLoading(`delete-${actionId}`); + try { + await apiClient.deleteEmployeeAction(actionId); + + // Перезагружаем данные + const refreshedData = await apiClient.getEmployee(userId); + setPerson(refreshedData); + } catch (err: any) { + console.error("Ошибка при удалении события:", err); + alert("Не удалось удалить событие"); + } finally { + setActionLoading(null); + } + }; + + // Сохранение кадрового события + const handleSaveAction = async () => { + if (!person || !userId || !actionType) return; + + setActionLoading("action"); + try { + if (editingActionId) { + // Редактирование существующего события + await apiClient.updateEmployeeAction(editingActionId, { + action: actionType, + date: actionDate, + comment: actionComment.trim() || undefined, + }); + } else { + // Создание нового события + await apiClient.createEmployeeAction({ + employee: userId, + action: actionType, + date: actionDate, + comment: actionComment.trim() || undefined, + }); + } + + // Перезагружаем данные сотрудника + const refreshedData = await apiClient.getEmployee(userId); + setPerson(refreshedData); + + handleCloseActionModal(); + } catch (err: any) { + console.error("Ошибка при создании события:", err); + alert("Не удалось сохранить событие"); + } finally { + setActionLoading(null); + } + }; + + // Открытие модального окна редактирования + const handleOpenEditModal = () => { + if (!person) return; + setEditFirstName(person.first_name || ""); + setEditLastName(person.last_name || ""); + setEditPatronymic(person.patronymic || ""); + setEditEmail(person.email || ""); + setEditPhone(person.phone_number || ""); + setEditTelegram(person.telegram || ""); + setEditWhatsApp(person.whatsapp || ""); + setEditWeChat(person.wechat || ""); + setEditAvatarFile(null); + setEditAvatarPreview(null); + setIsEditModalOpen(true); + }; + + // Закрытие модального окна + const handleCloseEditModal = () => { + setIsEditModalOpen(false); + setEditAvatarFile(null); + setEditAvatarPreview(null); + }; + + // Обработка выбора аватара + const handleAvatarChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Проверка размера (макс 5MB) + if (file.size > 5 * 1024 * 1024) { + alert("Файл слишком большой. Максимальный размер: 5MB"); + return; + } + + // Проверка типа + if (!file.type.startsWith("image/")) { + alert("Можно загружать только изображения"); + return; + } + + setEditAvatarFile(file); + + // Создаём preview + const reader = new FileReader(); + reader.onloadend = () => { + setEditAvatarPreview(reader.result as string); + }; + reader.readAsDataURL(file); + }; + + // Сохранение изменений + const handleSaveEdit = async () => { + if (!person || !userId) return; + + setActionLoading("edit"); + try { + // Обновляем данные сотрудника + const updatedData = await apiClient.updateEmployee(userId, { + first_name: editFirstName.trim(), + last_name: editLastName.trim(), + patronymic: editPatronymic.trim() || undefined, + email: editEmail.trim(), + phone_number: editPhone.trim() || undefined, + telegram: editTelegram.trim() || undefined, + whatsapp: editWhatsApp.trim() || undefined, + wechat: editWeChat.trim() || undefined, + }); + + // Загружаем аватар если выбран + if (editAvatarFile) { + await apiClient.uploadEmployeeAvatar(userId, editAvatarFile); + } + + // Перезагружаем данные сотрудника + const refreshedData = await apiClient.getEmployee(userId); + setPerson(refreshedData); + + handleCloseEditModal(); + } catch (err: any) { + console.error("Ошибка при обновлении сотрудника:", err); + alert("Не удалось обновить данные"); + } finally { + setActionLoading(null); + } + }; + + // Проверка прав на редактирование + const canEdit = currentUser && ( + currentUser.auth?.is_staff || + currentUser.auth?.is_superuser || + currentUser.id === userId + ); + + // Проверка прав на управление кадровыми событиями (только админы) + const canManageActions = currentUser && ( + currentUser.auth?.is_staff || + currentUser.auth?.is_superuser + ); + + // Список типов кадровых событий + const actionTypes = [ + { value: 'hired', label: 'Принят' }, + { value: 'dismissed', label: 'Уволен' }, + { value: 'on_leave', label: 'В отпуске' }, + { value: 'returned_from_leave', label: 'Вернулся из отпуска' }, + { value: 'on_maternity', label: 'В декрете' }, + { value: 'returned_from_maternity', label: 'Вернулся из декрета' }, + { value: 'transferred', label: 'Переведен' }, + { value: 'rehired', label: 'Восстановлен' }, + ]; + return (
- ← К списку пользователей + ← К списку сотрудников {loading ? (
-

Загрузка пользователя...

+

Загрузка...

) : error ? (

{error}

) : person ? ( -
-
-
- {avatarUrl && !avatarFailed ? ( - {fullName} setAvatarFailed(true)} - /> - ) : ( - {initials} - )} -
+ <> + {/* Основная информация */} +
+
+
+ {avatarUrl && !avatarFailed ? ( + {fullName} setAvatarFailed(true)} + /> + ) : ( + {initials} + )} +
-
-

{fullName}

-

Должность: {person.position?.name || "—"}

-

Email: {person.email || "—"}

-

Телефон: {person.phone_number || "—"}

- {person.telegram ?

Telegram: {person.telegram}

: null} - {person.whatsapp ?

WhatsApp: {person.whatsapp}

: null} - {person.wechat ?

WeChat: {person.wechat}

: null} +
+
+

{fullName}

+ {canEdit && ( + + )} +
+
+

{person.position?.name || "—"}

+ {latestAction && ( + + {latestAction.action_display || latestAction.action} + + )} + {canManageActions && ( + + )} +
+ {person.departments && person.departments.length > 0 && ( +

{person.departments[0].name}

+ )} + + {currentUser && currentUser.id !== person.id && ( +
+ + + {person.phone_number && ( + + + Позвонить + + )} +
+ )} +
+
+ + {/* Контакты и детали */} +
+ {/* Контакты */} +
+

Контакты

+
+ {person.email && ( +
+ + +
+ )} + + {person.phone_number && ( +
+ + +
+ )} + + {person.telegram && ( +
+ + + {person.telegram} + + Telegram +
+ )} + + {person.whatsapp && ( +
+ + + {person.whatsapp} + + WhatsApp +
+ )} + + {person.wechat && ( +
+ + {person.wechat} + WeChat +
+ )} + + {!person.email && !person.phone_number && !person.telegram && !person.whatsapp && !person.wechat && ( +

Нет контактов

+ )} +
+
+ + {/* Навыки */} +
+

+ + Навыки +

+ {person.skills && person.skills.length > 0 ? ( +
+ {person.skills.map((skill) => ( + + {skill.name} + + ))} +
+ ) : ( +

Навыки не указаны

+ )} +
+ + {/* Информация */} + {(person.date_joined || person.birth_date) && ( +
+

Информация

+
+ {person.date_joined && ( + <> +
+ +
+

В компании

+

+ {getWorkDuration(person.date_joined)} +

+
+
+
+ +
+

Дата найма

+

+ {new Date(person.date_joined).toLocaleDateString('ru-RU')} +

+
+
+ + )} + + {person.birth_date && ( +
+ +
+

День рождения

+

+ {formatBirthday(person.birth_date)} +

+
+
+ )} +
+
+ )}
-
-

Отделы

- {(person.departments || []).length === 0 ? ( -

Не указаны

- ) : ( + {/* Отделы */} + {person.departments && person.departments.length > 0 && ( +
+

+ + Отделы +

- {(person.departments || []).map((d) => ( - - {d.name} - {d.is_head ? Руководитель : null} + {person.departments.map((dept) => ( + +
+

{dept.name}

+ {dept.role_name && ( +

{dept.role_name}

+ )} +
+ {dept.is_head && ( + + Руководитель + + )} ))}
- )} -
-
+ + )} + + {/* История кадровых событий */} + {sortedActions.length > 0 && ( +
+
+

+ + История кадровых событий +

+
+ + {sortedActions.length} {sortedActions.length === 1 ? 'событие' : sortedActions.length < 5 ? 'события' : 'событий'} + + {canManageActions && ( + + )} +
+
+
+ {sortedActions.map((action, index) => ( +
+
+
+
+
+ + {action.action_display || action.action} + + {index === 0 && ( + текущий + )} +
+ {action.comment && ( +

{action.comment}

+ )} +
+
+ + {canManageActions && ( +
+ + +
+ )} +
+
+
+
+ ))} +
+
+ )} + ) : null}
+ + {/* Модальное окно редактирования */} + {isEditModalOpen && person && ( +
+
+
+

Редактировать профиль

+ +
+ +
+ {/* Аватар */} +
+ +
+
+ {editAvatarPreview ? ( + Preview + ) : avatarUrl && !avatarFailed ? ( + Avatar + ) : ( + {initials} + )} +
+ +
+

+ Максимальный размер: 5MB. Форматы: JPG, PNG, GIF +

+
+ + {/* ФИО */} +
+
+ + setEditLastName(e.target.value)} + placeholder="Иванов" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + maxLength={150} + /> +
+ +
+ + setEditFirstName(e.target.value)} + placeholder="Иван" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + maxLength={150} + /> +
+ +
+ + setEditPatronymic(e.target.value)} + placeholder="Иванович" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + maxLength={150} + /> +
+
+ + {/* Контакты */} +
+
+ + setEditEmail(e.target.value)} + placeholder="ivan@example.com" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+ +
+ + setEditPhone(e.target.value)} + placeholder="+7 999 123-45-67" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+
+ + {/* Мессенджеры */} +
+
+ + setEditTelegram(e.target.value)} + placeholder="@username" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+ +
+ + setEditWhatsApp(e.target.value)} + placeholder="+7 999 123-45-67" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+ +
+ + setEditWeChat(e.target.value)} + placeholder="username" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+
+
+ + {/* Кнопки */} +
+ + +
+
+
+ )} + + {/* Модальное окно создания кадрового события */} + {isActionModalOpen && person && ( +
+
+
+

+ {editingActionId ? 'Редактировать событие' : 'Кадровое событие'} +

+ +
+ +
+ {/* Тип события */} +
+ + +
+ + {/* Дата */} +
+ + setActionDate(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+ + {/* Комментарий */} +
+ +