""" Views для системы чата. """ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from django.db.models import Q, Max from django.utils import timezone from .models import Chat, ChatParticipant, Message, MessageFile from .serializers import ( ChatSerializer, ChatDetailSerializer, MessageSerializer, MessageCreateSerializer, ChatParticipantSerializer ) from .permissions import IsChatParticipant from .services import ChatService from .utils import ( save_file_to_preload, move_file_from_preload_to_chat, cleanup_preload_files ) class ChatListPagination(PageNumberPagination): page_size = 30 page_size_query_param = 'page_size' max_page_size = 100 class ChatViewSet(viewsets.ModelViewSet): """ViewSet для чатов.""" queryset = Chat.objects.all() serializer_class = ChatSerializer permission_classes = [IsAuthenticated] lookup_field = 'uuid' pagination_class = ChatListPagination def get_queryset(self): """Только чаты пользователя со связанными участниками.""" archived = self.request.query_params.get('archived', 'false').lower() == 'true' user = self.request.user queryset = Chat.objects.filter( participants__user=user, is_archived=archived ) # Если не суперпользователь, фильтруем по связям if not user.is_superuser: from apps.users.models import User contact_ids = set() if user.role == 'mentor': # Студенты ментора contact_ids.update(User.objects.filter(role='client', client_profile__mentors=user).values_list('id', flat=True)) # Родители студентов ментора contact_ids.update(User.objects.filter(role='parent', parent_profile__children__mentors=user).values_list('id', flat=True)) elif user.role == 'client': # Менторы студента contact_ids.update(User.objects.filter(role='mentor', clients__user=user).values_list('id', flat=True)) # Родители студента contact_ids.update(User.objects.filter(role='parent', parent_profile__children__user=user).values_list('id', flat=True)) elif user.role == 'parent': # Менторы детей родителя contact_ids.update(User.objects.filter(role='mentor', clients__parents__user=user).values_list('id', flat=True)) # Дети родителя contact_ids.update(User.objects.filter(role='client', client_profile__parents__user=user).values_list('id', flat=True)) # Фильтруем чаты: либо групповой, либо личный со связанным пользователем # Для личных чатов проверяем, что второй участник (не текущий юзер) есть в списке контактов queryset = queryset.filter( Q(chat_type='group') | Q(chat_type='direct', participants__user_id__in=contact_ids) ) queryset = queryset.annotate( last_msg_time=Max('messages__created_at') ).select_related('created_by') # Для личных чатов нужно предзагрузить участников с их данными from django.db.models import Prefetch queryset = queryset.prefetch_related( Prefetch( 'participants', queryset=ChatParticipant.objects.select_related('user') ) ) # Оптимизация: для списка prefetch только последнее сообщение вместо всех if self.action == 'list': from django.db.models import Prefetch queryset = queryset.prefetch_related( Prefetch( 'messages', queryset=Message.objects.filter(is_deleted=False).select_related('sender').prefetch_related('files').order_by('-created_at')[:1], to_attr='last_message_prefetch' ) ) else: # Для детального просмотра загружаем все сообщения queryset = queryset.prefetch_related('messages') queryset = queryset.order_by('-last_msg_time', '-created_at').distinct() # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'uuid', 'chat_type', 'name', 'description', 'avatar', 'created_by_id', 'lesson_id', 'messages_count', 'last_message_at', 'is_archived', 'created_at' ) return queryset def get_serializer_class(self): """Выбор сериализатора.""" if self.action == 'retrieve': return ChatDetailSerializer return ChatSerializer @action(detail=True, methods=['get']) def messages(self, request, uuid=None): """ Получить сообщения чата. GET /api/chat/chats/{uuid}/messages/ """ chat = self.get_object() messages = Message.objects.filter( chat=chat, is_deleted=False ).select_related('sender', 'reply_to', 'reply_to__sender').prefetch_related( 'files', 'reactions__user', 'reads' # Предзагружаем reads для get_is_read ) # Сортируем по убыванию даты (последние сообщения сначала) # НЕ используем only() так как нужны все поля для сериализации (sender, files, reactions) # only() может вызвать дополнительные запросы при доступе к связанным полям messages = messages.order_by('-created_at') # Используем пагинацию для сообщений from rest_framework.pagination import PageNumberPagination paginator = PageNumberPagination() paginator.page_size = 30 # Размер страницы по умолчанию # Получаем параметры пагинации из запроса page_size = request.query_params.get('page_size', '30') try: page_size = int(page_size) if page_size > 0 and page_size <= 100: # Ограничиваем максимум 100 paginator.page_size = page_size except (ValueError, TypeError): pass # Применяем пагинацию page = paginator.paginate_queryset(messages, request) if page is not None: serializer = MessageSerializer(page, many=True, context={'request': request}) return paginator.get_paginated_response(serializer.data) # Если пагинация не применилась (не должно быть), ограничиваем вручную limited_messages = messages[:30] serializer = MessageSerializer(limited_messages, many=True, context={'request': request}) return Response({ 'count': messages.count(), 'next': None, 'previous': None, 'results': serializer.data }) @action(detail=False, methods=['post']) def create_direct(self, request): """ Создать личный чат с пользователем. Проверяет наличие связи между пользователями. """ other_user_id = request.data.get('user_id') if not other_user_id: return Response({ 'success': False, 'error': 'user_id обязателен' }, status=status.HTTP_400_BAD_REQUEST) # Проверяем связь from apps.users.models import User, Client, Parent try: other_user = User.objects.get(id=other_user_id) except User.DoesNotExist: return Response({ 'success': False, 'error': 'Пользователь не найден' }, status=status.HTTP_404_NOT_FOUND) # Логика проверки связей has_connection = False user = request.user if user.role == 'mentor': # Ментор <-> Студент if other_user.role == 'client' and Client.objects.filter(user=other_user, mentors=user).exists(): has_connection = True # Ментор <-> Родитель студента elif other_user.role == 'parent' and Parent.objects.filter(user=other_user, children__mentors=user).exists(): has_connection = True elif user.role == 'client': # Студент <-> Ментор if other_user.role == 'mentor' and Client.objects.filter(user=user, mentors=other_user).exists(): has_connection = True # Студент <-> Родитель elif other_user.role == 'parent' and Parent.objects.filter(user=other_user, children__user=user).exists(): has_connection = True elif user.role == 'parent': # Родитель <-> Ментор if other_user.role == 'mentor' and User.objects.filter(id=other_user.id, role='mentor', clients__parents__user=user).exists(): has_connection = True # Родитель <-> Ребенок elif other_user.role == 'client' and Client.objects.filter(user=other_user, parents__user=user).exists(): has_connection = True if not has_connection and not user.is_superuser: return Response({ 'success': False, 'error': 'Вы можете создавать чаты только со связанными пользователями' }, status=status.HTTP_403_FORBIDDEN) # Используем сервис для атомарного создания/получения чата chat, created = ChatService.get_or_create_direct_chat( user1=request.user, user2=other_user, created_by=request.user ) serializer = ChatDetailSerializer(chat) if created: return Response({ 'success': True, 'data': serializer.data }, status=status.HTTP_201_CREATED) else: return Response({ 'success': True, 'data': serializer.data, 'message': 'Чат уже существует' }) @action(detail=True, methods=['post']) def mark_read(self, request, uuid=None): """ Отметить сообщения как прочитанные. POST /api/chat/chats/{uuid}/mark_read/ Body: { "message_uuids": ["uuid1", "uuid2", ...] # опционально, если не указано - все сообщения } """ chat = self.get_object() try: participant = chat.participants.get(user=request.user) except ChatParticipant.DoesNotExist: return Response({ 'success': False, 'error': 'Вы не участник этого чата' }, status=status.HTTP_403_FORBIDDEN) # Если указаны конкретные UUID сообщений message_uuids = request.data.get('message_uuids', []) if message_uuids: from .models import MessageRead messages = Message.objects.filter( chat=chat, uuid__in=message_uuids, is_deleted=False ) # Оптимизация: получаем существующие записи о прочтении одним запросом existing_reads = set( MessageRead.objects.filter( message__in=messages, user=request.user ).values_list('message_id', flat=True) ) # Создаем только новые записи через bulk_create messages_list = list(messages) new_reads = [ MessageRead(message=message, user=request.user) for message in messages_list if message.id not in existing_reads ] if new_reads: MessageRead.objects.bulk_create(new_reads, ignore_conflicts=True) # Собираем UUID новых прочитанных сообщений read_message_uuids = [ str(message.uuid) for message in messages_list if message.id not in existing_reads ] # Отправляем уведомление через WebSocket о прочтении сообщений if read_message_uuids: from channels.layers import get_channel_layer from asgiref.sync import async_to_sync try: channel_layer = get_channel_layer() if channel_layer: room_group_name = f'chat_{chat.uuid}' async_to_sync(channel_layer.group_send)( room_group_name, { 'type': 'message_read', 'user_id': request.user.id, 'message_uuids': read_message_uuids } ) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error sending message_read via WebSocket: {e}", exc_info=True) # Уведомляем по WS об обновлении бейджей нижнего меню try: from apps.notifications.services import WebSocketNotificationService WebSocketNotificationService.send_nav_badges_updated(request.user.id) except Exception: pass # Пересчитываем непрочитанные (системные сообщения не учитываем) unread_count = Message.objects.filter( chat=chat, is_deleted=False ).exclude( message_type='system' ).exclude( reads__user=request.user ).exclude( sender=request.user ).count() participant.unread_count = unread_count participant.save(update_fields=['unread_count']) else: # Отмечаем все сообщения как прочитанные from .models import MessageRead messages = Message.objects.filter( chat=chat, is_deleted=False ).exclude(sender=request.user) # Оптимизация: получаем существующие записи о прочтении одним запросом messages_list = list(messages) existing_reads = set( MessageRead.objects.filter( message__in=messages_list, user=request.user ).values_list('message_id', flat=True) ) # Создаем только новые записи через bulk_create new_reads = [ MessageRead(message=message, user=request.user) for message in messages_list if message.id not in existing_reads ] if new_reads: MessageRead.objects.bulk_create(new_reads, ignore_conflicts=True) # Обновляем счетчик и время последнего прочтения participant.unread_count = 0 participant.last_read_at = timezone.now() participant.save(update_fields=['unread_count', 'last_read_at']) # Уведомляем по WS об обновлении бейджей нижнего меню try: from apps.notifications.services import WebSocketNotificationService WebSocketNotificationService.send_nav_badges_updated(request.user.id) except Exception: pass return Response({ 'success': True, 'message': 'Сообщения отмечены как прочитанные' }) @action(detail=False, methods=['get', 'post']) def lesson_chat(self, request): """ Получить или создать личный чат между участниками урока. Ищет существующий личный чат между ментором и клиентом урока. Если чата нет, создает новый личный чат. GET /api/chat/chats/lesson_chat/?lesson_id=123 POST /api/chat/chats/lesson_chat/ Body: { "lesson_id": 123 } """ lesson_id = request.query_params.get('lesson_id') or request.data.get('lesson_id') if not lesson_id: return Response({ 'success': False, 'error': 'lesson_id обязателен' }, status=status.HTTP_400_BAD_REQUEST) try: from apps.schedule.models import Lesson lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id) except Lesson.DoesNotExist: return Response({ 'success': False, 'error': 'Урок не найден' }, status=status.HTTP_404_NOT_FOUND) # Определяем участников чата: ментор и клиент mentor = lesson.mentor client_user = lesson.client.user if lesson.client and lesson.client.user else None if not mentor or not client_user: return Response({ 'success': False, 'error': 'У урока должны быть указаны ментор и клиент' }, status=status.HTTP_400_BAD_REQUEST) # Используем сервис для атомарного создания/получения чата chat, created = ChatService.get_or_create_direct_chat( user1=mentor, user2=client_user, created_by=mentor ) # Если текущий пользователь не участник чата (родитель), добавляем его if request.user != mentor and request.user != client_user: ChatService.ensure_participant(chat, request.user, role='member') serializer = ChatDetailSerializer(chat, context={'request': request}) if created: return Response({ 'success': True, 'data': serializer.data }, status=status.HTTP_201_CREATED) else: return Response({ 'success': True, 'data': serializer.data }) @action(detail=True, methods=['post']) def mark_as_read(self, request, uuid=None): """ Отметить все сообщения как прочитанные (алиас для mark_read). POST /api/chat/chats/{uuid}/mark_as_read/ """ return self.mark_read(request, uuid) @action(detail=True, methods=['post']) def archive(self, request, uuid=None): """ Архивировать чат. POST /api/chat/chats/{uuid}/archive/ """ chat = self.get_object() chat.is_archived = True chat.save() return Response({ 'success': True, 'message': 'Чат архивирован' }) @action(detail=True, methods=['post']) def unarchive(self, request, uuid=None): """ Разархивировать чат. POST /api/chat/chats/{uuid}/unarchive/ """ chat = self.get_object() chat.is_archived = False chat.save() return Response({ 'success': True, 'message': 'Чат разархивирован' }) @action(detail=True, methods=['post']) def upload_files(self, request, uuid=None): """ Предзагрузка файлов в директорию preload_file_chat{id чата}. POST /api/chat/chats/{uuid}/upload_files/ Body: FormData с файлами """ chat = self.get_object() # Проверяем доступ if not ChatParticipant.objects.filter(chat=chat, user=request.user).exists(): return Response({ 'success': False, 'error': 'У вас нет доступа к этому чату' }, status=status.HTTP_403_FORBIDDEN) uploaded_files = [] errors = [] # Обрабатываем все переданные файлы files = request.FILES.getlist('files') if not files: return Response({ 'success': False, 'error': 'Файлы не переданы' }, status=status.HTTP_400_BAD_REQUEST) for file in files: try: import uuid as uuid_lib import os # Генерируем уникальное имя файла ext = os.path.splitext(file.name)[1] unique_filename = f"{uuid_lib.uuid4()}{ext}" # Сохраняем в preload директорию file_path = save_file_to_preload(chat.id, file, unique_filename) uploaded_files.append({ 'original_name': file.name, 'filename': unique_filename, 'file_path': file_path, 'size': file.size, 'content_type': file.content_type }) except Exception as e: errors.append({ 'filename': file.name, 'error': str(e) }) return Response({ 'success': True, 'files': uploaded_files, 'errors': errors if errors else None }, status=status.HTTP_201_CREATED) class MessageViewSet(viewsets.ModelViewSet): """ViewSet для сообщений.""" queryset = Message.objects.all() serializer_class = MessageSerializer permission_classes = [IsAuthenticated, IsChatParticipant] lookup_field = 'uuid' def get_queryset(self): """Сообщения чата.""" chat_uuid = self.request.query_params.get('chat') if chat_uuid: queryset = Message.objects.filter( chat__uuid=chat_uuid, is_deleted=False ).select_related('sender', 'chat', 'reply_to', 'reply_to__sender').prefetch_related( 'files', 'reactions__user', 'reads' # Предзагружаем reads для get_is_read ) # НЕ используем only() так как нужны все поля для сериализации (sender, files, reactions) # only() может вызвать дополнительные запросы при доступе к связанным полям return queryset.order_by('-created_at') return Message.objects.none() def get_serializer_class(self): """Выбор сериализатора.""" if self.action == 'create': return MessageCreateSerializer return MessageSerializer def create(self, request, *args, **kwargs): """Создание сообщения.""" # Создаем копию данных data = request.data.copy() # Обрабатываем preloaded_files из FormData (QueryDict может вернуть список) if 'preloaded_files' in data: preloaded_files = data.get('preloaded_files') # Если это список (из QueryDict), берем первый элемент (строку JSON) if isinstance(preloaded_files, list) and len(preloaded_files) > 0: data['preloaded_files'] = preloaded_files[0] # Если это уже строка, оставляем как есть elif not isinstance(preloaded_files, str): data['preloaded_files'] = '' serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) # Создаем сообщение (sender уже устанавливается в сериализаторе) message = serializer.save() # Отправляем уведомления from apps.notifications.services import NotificationService NotificationService.send_message_notification(message) # Отправляем сообщение через WebSocket from channels.layers import get_channel_layer from asgiref.sync import async_to_sync from .serializers import MessageSerializer try: channel_layer = get_channel_layer() if channel_layer: # Сериализуем сообщение для отправки через WebSocket message_serializer = MessageSerializer(message, context={'request': request}) message_data = message_serializer.data # Отправляем в группу чата room_group_name = f'chat_{message.chat.uuid}' async_to_sync(channel_layer.group_send)( room_group_name, { 'type': 'chat_message', 'message': message_data } ) # Уведомляем других участников чата об обновлении бейджей from apps.notifications.services import WebSocketNotificationService from .models import ChatParticipant for p in ChatParticipant.objects.filter(chat=message.chat).exclude(user=request.user).select_related('user'): WebSocketNotificationService.send_nav_badges_updated(p.user.id) except Exception as e: # Логируем ошибку, но не прерываем создание сообщения import logging logger = logging.getLogger(__name__) logger.error(f"Error sending message via WebSocket: {e}", exc_info=True) # Возвращаем полные данные сообщения output_serializer = MessageSerializer(message, context={'request': request}) return Response({ 'success': True, 'data': output_serializer.data }, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): """Редактирование сообщения.""" message = self.get_object() # Проверяем что пользователь - отправитель if message.sender != request.user: return Response({ 'success': False, 'error': 'Вы можете редактировать только свои сообщения' }, status=status.HTTP_403_FORBIDDEN) serializer = self.get_serializer(message, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() # Отмечаем как отредактированное message.mark_as_edited() return Response({ 'success': True, 'data': serializer.data }) def destroy(self, request, *args, **kwargs): """Удаление сообщения.""" message = self.get_object() # Проверяем что пользователь - отправитель if message.sender != request.user: return Response({ 'success': False, 'error': 'Вы можете удалять только свои сообщения' }, status=status.HTTP_403_FORBIDDEN) # Мягкое удаление message.soft_delete() return Response({ 'success': True, 'message': 'Сообщение удалено' }, status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=['post']) def react(self, request, uuid=None): """ Добавить реакцию на сообщение. POST /api/chat/messages/{uuid}/react/ Body: { "emoji": "👍" } """ message = self.get_object() emoji = request.data.get('emoji') if not emoji: return Response({ 'success': False, 'error': 'emoji обязателен' }, status=status.HTTP_400_BAD_REQUEST) from .models import MessageReaction # Создаем или удаляем реакцию reaction, created = MessageReaction.objects.get_or_create( message=message, user=request.user, emoji=emoji ) if not created: # Если уже есть такая реакция, удаляем reaction.delete() return Response({ 'success': True, 'message': 'Реакция удалена' }) return Response({ 'success': True, 'message': 'Реакция добавлена' }, status=status.HTTP_201_CREATED)