uchill/backend/apps/chat/views.py

744 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)