uchill/backend/apps/chat/serializers.py

583 lines
26 KiB
Python
Raw 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.

"""
Сериализаторы для чата и сообщений.
"""
from rest_framework import serializers
from django.db import models
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
from apps.users.serializers import UserSerializer
from apps.users.mixins import TimezoneAwareSerializerMixin
from apps.users.utils import format_datetime_for_user
class ChatParticipantSerializer(serializers.ModelSerializer):
"""Сериализатор участника чата."""
user = UserSerializer(read_only=True)
class Meta:
model = ChatParticipant
fields = [
'id',
'user',
'role',
'unread_count',
'last_read_at',
'is_muted',
'is_pinned',
'joined_at'
]
read_only_fields = ['unread_count', 'last_read_at', 'joined_at']
class MessageFileSerializer(serializers.ModelSerializer):
"""Сериализатор файла сообщения."""
file = serializers.SerializerMethodField()
class Meta:
model = MessageFile
fields = [
'id',
'file',
'file_name',
'file_size',
'file_type',
'created_at'
]
read_only_fields = ['file_name', 'file_size', 'file_type', 'created_at']
def get_file(self, obj):
"""Получить полный URL файла."""
request = self.context.get('request')
if request and obj.file:
return request.build_absolute_uri(obj.file.url)
elif obj.file:
# Если нет request, возвращаем относительный URL
return obj.file.url
return None
class MessageReactionSerializer(serializers.ModelSerializer):
"""Сериализатор реакции на сообщение."""
user = UserSerializer(read_only=True)
class Meta:
model = MessageReaction
fields = ['id', 'user', 'emoji', 'created_at']
read_only_fields = ['user', 'created_at']
class MessageSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
"""Сериализатор сообщения."""
sender = UserSerializer(read_only=True)
files = MessageFileSerializer(many=True, read_only=True)
reactions = MessageReactionSerializer(many=True, read_only=True)
reply_to = serializers.SerializerMethodField()
is_read = serializers.SerializerMethodField()
is_read_by_others = serializers.SerializerMethodField()
class Meta:
model = Message
fields = [
'id',
'uuid',
'chat',
'sender',
'message_type',
'content',
'reply_to',
'files',
'reactions',
'is_edited',
'edited_at',
'is_deleted',
'is_read',
'is_read_by_others',
'created_at'
]
read_only_fields = [
'uuid',
'sender',
'is_edited',
'edited_at',
'is_deleted',
'created_at'
]
timezone_aware_fields = ['created_at', 'edited_at']
def get_reply_to(self, obj):
"""Получить сообщение, на которое отвечают."""
if obj.reply_to:
return {
'uuid': str(obj.reply_to.uuid),
'sender': obj.reply_to.sender.get_full_name() if obj.reply_to.sender else 'System',
'content': obj.reply_to.content[:100]
}
return None
def get_is_read(self, obj):
"""Проверка прочитано ли сообщение текущим пользователем."""
request = self.context.get('request')
if request and request.user.is_authenticated:
# Оптимизация: используем предзагруженные reads если доступны
if hasattr(obj, '_prefetched_objects_cache') and 'reads' in obj._prefetched_objects_cache:
reads = obj._prefetched_objects_cache['reads']
return any(read.user_id == request.user.id for read in reads)
# Fallback на запрос, если prefetch не был выполнен
return MessageRead.objects.filter(
message=obj,
user=request.user
).exists()
return False
def get_is_read_by_others(self, obj):
"""Проверка прочитано ли сообщение другими участниками чата (для отображения статуса прочитанности)."""
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return False
# Проверяем только для сообщений, отправленных текущим пользователем
if obj.sender_id != request.user.id:
return False
# Получаем всех участников чата кроме отправителя
chat = obj.chat
other_participants = chat.participants.exclude(user_id=obj.sender_id)
if not other_participants.exists():
return False
# Проверяем, прочитано ли сообщение хотя бы одним другим участником
# Оптимизация: используем предзагруженные reads если доступны
if hasattr(obj, '_prefetched_objects_cache') and 'reads' in obj._prefetched_objects_cache:
reads = obj._prefetched_objects_cache['reads']
other_participant_ids = set(other_participants.values_list('user_id', flat=True))
return any(read.user_id in other_participant_ids for read in reads)
# Fallback на запрос
other_participant_ids = list(other_participants.values_list('user_id', flat=True))
return MessageRead.objects.filter(
message=obj,
user_id__in=other_participant_ids
).exists()
class MessageCreateSerializer(serializers.ModelSerializer):
"""Сериализатор создания сообщения."""
reply_to_uuid = serializers.UUIDField(required=False, allow_null=True)
content = serializers.CharField(required=False, allow_blank=True)
files = serializers.ListField(
child=serializers.FileField(),
required=False,
allow_empty=True
)
preloaded_files = serializers.CharField(
required=False,
allow_blank=True,
help_text="JSON строка со списком предзагруженных файлов: [{'filename': 'uuid.ext', 'original_name': 'name.ext', 'size': 1234, 'content_type': 'image/jpeg'}]"
)
class Meta:
model = Message
fields = ['chat', 'content', 'message_type', 'reply_to_uuid', 'files', 'preloaded_files']
def validate(self, attrs):
"""Валидация."""
# Проверяем доступ к чату
request = self.context['request']
chat = attrs['chat']
if not ChatParticipant.objects.filter(chat=chat, user=request.user).exists():
raise serializers.ValidationError({
'chat': 'У вас нет доступа к этому чату'
})
# Проверяем, что есть либо content, либо файлы
content = attrs.get('content', '').strip()
files = attrs.get('files', [])
preloaded_files = attrs.get('preloaded_files', [])
# Парсим preloaded_files если это строка
if isinstance(preloaded_files, str):
import json
try:
preloaded_files = json.loads(preloaded_files)
except json.JSONDecodeError:
preloaded_files = []
has_content = bool(content)
has_files = bool(files) or bool(preloaded_files)
if not has_content and not has_files:
raise serializers.ValidationError({
'content': 'Сообщение не может быть пустым. Укажите текст или прикрепите файлы.'
})
# Проверяем reply_to
reply_to_uuid = attrs.pop('reply_to_uuid', None)
if reply_to_uuid:
try:
attrs['reply_to'] = Message.objects.get(uuid=reply_to_uuid, chat=chat)
except Message.DoesNotExist:
raise serializers.ValidationError({
'reply_to_uuid': 'Сообщение не найдено'
})
# Устанавливаем пустую строку для content если его нет
if not content:
attrs['content'] = ''
return attrs
def create(self, validated_data):
"""Создание сообщения."""
files_data = validated_data.pop('files', [])
preloaded_files_data = validated_data.pop('preloaded_files', '')
user = self.context['request'].user
chat = validated_data['chat']
message = Message.objects.create(
sender=user,
**validated_data
)
# Обрабатываем предзагруженные файлы
from .utils import move_file_from_preload_to_chat
from django.core.files import File
from django.conf import settings
import os
import json
# Парсим JSON строку
preloaded_files_list = []
if preloaded_files_data:
if isinstance(preloaded_files_data, str):
try:
parsed = json.loads(preloaded_files_data)
if isinstance(parsed, list):
preloaded_files_list = [item for item in parsed if isinstance(item, dict)]
except json.JSONDecodeError:
preloaded_files_list = []
elif isinstance(preloaded_files_data, list):
preloaded_files_list = [item for item in preloaded_files_data if isinstance(item, dict)]
for preloaded_file in preloaded_files_list:
try:
filename = preloaded_file.get('filename')
original_name = preloaded_file.get('original_name', filename)
file_size = preloaded_file.get('size', 0)
content_type = preloaded_file.get('content_type', 'application/octet-stream')
# Перемещаем файл из preload в основную директорию
new_file_path = move_file_from_preload_to_chat(chat.id, filename)
# Создаем запись MessageFile
full_path = os.path.join(settings.MEDIA_ROOT, new_file_path)
with open(full_path, 'rb') as f:
django_file = File(f, name=os.path.basename(new_file_path))
MessageFile.objects.create(
message=message,
file=django_file,
file_name=original_name,
file_size=file_size,
file_type=content_type
)
except Exception as e:
# Логируем ошибку, но не прерываем создание сообщения
import logging
logger = logging.getLogger(__name__)
logger.error(f"Ошибка при обработке предзагруженного файла {preloaded_file}: {e}")
# Обрабатываем файлы, загруженные напрямую (для обратной совместимости)
for file in files_data:
MessageFile.objects.create(
message=message,
file=file,
file_name=file.name,
file_size=file.size,
file_type=file.content_type
)
return message
class ChatSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
"""Сериализатор чата (список)."""
created_by = UserSerializer(read_only=True)
last_message = serializers.SerializerMethodField()
my_participant = serializers.SerializerMethodField()
participants_count = serializers.SerializerMethodField()
other_participant = serializers.SerializerMethodField()
class Meta:
model = Chat
fields = [
'id',
'uuid',
'chat_type',
'name',
'description',
'avatar',
'created_by',
'lesson',
'participants_count',
'last_message',
'my_participant',
'other_participant',
'messages_count',
'last_message_at',
'is_archived',
'created_at'
]
read_only_fields = [
'uuid',
'created_by',
'messages_count',
'last_message_at',
'created_at'
]
timezone_aware_fields = ['created_at', 'last_message_at']
def get_participants_count(self, obj):
"""Количество участников."""
# Оптимизация: используем предзагруженные participants если доступны
if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache:
return len(obj._prefetched_objects_cache['participants'])
return obj.participants.count()
def get_last_message(self, obj):
"""Получить последнее сообщение."""
# Оптимизация: используем предзагруженные messages если доступны
if hasattr(obj, '_prefetched_objects_cache') and 'messages' in obj._prefetched_objects_cache:
messages = [m for m in obj._prefetched_objects_cache['messages'] if not m.is_deleted]
last_message = messages[0] if messages else None
else:
last_message = obj.messages.filter(is_deleted=False).select_related('sender').prefetch_related('files', 'reads').first()
if last_message:
# Получаем информацию о файлах
files_data = []
if hasattr(last_message, '_prefetched_objects_cache') and 'files' in last_message._prefetched_objects_cache:
files = last_message._prefetched_objects_cache['files']
else:
files = last_message.files.all()
for file in files:
files_data.append({
'id': file.id,
'file_name': file.file_name,
'file_type': file.file_type,
'file_size': file.file_size
})
# Проверяем, прочитано ли сообщение другими участниками (для отображения статуса)
is_read_by_others = False
request = self.context.get('request')
if request and request.user.is_authenticated and last_message.sender_id == request.user.id:
# Получаем всех участников чата кроме отправителя
other_participants = obj.participants.exclude(user_id=last_message.sender_id)
if other_participants.exists():
# Проверяем, прочитано ли сообщение хотя бы одним другим участником
if hasattr(last_message, '_prefetched_objects_cache') and 'reads' in last_message._prefetched_objects_cache:
reads = last_message._prefetched_objects_cache['reads']
other_participant_ids = set(other_participants.values_list('user_id', flat=True))
is_read_by_others = any(read.user_id in other_participant_ids for read in reads)
else:
other_participant_ids = list(other_participants.values_list('user_id', flat=True))
is_read_by_others = MessageRead.objects.filter(
message=last_message,
user_id__in=other_participant_ids
).exists()
return {
'uuid': str(last_message.uuid),
'sender': last_message.sender.get_full_name() if last_message.sender else 'System',
'sender_id': last_message.sender_id,
'content': last_message.content[:100] if last_message.content else '',
'message_type': last_message.message_type,
'files': files_data,
'is_read_by_others': is_read_by_others,
'created_at': format_datetime_for_user(last_message.created_at, request.user.timezone) if last_message.created_at and request and request.user.is_authenticated else (last_message.created_at.isoformat() if last_message.created_at else None)
}
return None
def get_my_participant(self, obj):
"""Получить данные участника для текущего пользователя."""
request = self.context.get('request')
if request and request.user.is_authenticated:
# Оптимизация: используем предзагруженные participants если доступны
if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache:
participants = obj._prefetched_objects_cache['participants']
participant = next((p for p in participants if p.user_id == request.user.id), None)
else:
try:
participant = obj.participants.get(user=request.user)
except ChatParticipant.DoesNotExist:
participant = None
if participant:
return {
'unread_count': participant.unread_count,
'is_muted': participant.is_muted,
'is_pinned': participant.is_pinned
}
return None
def get_other_participant(self, obj):
"""Получить информацию о собеседнике для личных чатов."""
if obj.chat_type != 'direct':
return None
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return None
# Оптимизация: используем предзагруженные participants если доступны
if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache:
participants = obj._prefetched_objects_cache['participants']
other_participant = next((p for p in participants if p.user_id != request.user.id), None)
else:
other_participant = obj.participants.exclude(user=request.user).select_related('user').first()
if other_participant and other_participant.user:
from datetime import timedelta
from django.utils import timezone
user = other_participant.user
# КРИТИЧНО: Обновляем объект пользователя из базы данных, чтобы получить актуальное значение last_activity
# Это необходимо, так как middleware мог обновить last_activity после загрузки объекта
try:
user.refresh_from_db(fields=['last_activity'])
except Exception:
# Если не удалось обновить, используем текущее значение
pass
# Определяем статус онлайн (активен в последние 15 минут)
# Интервал 15 минут для определения онлайн статуса
is_online = False
if user.last_activity:
time_diff = timezone.now() - user.last_activity
# Пользователь считается онлайн если активен в последние 15 минут
is_online = time_diff.total_seconds() < 900 # 15 минут = 900 секунд
# Если last_activity отсутствует, пользователь точно не онлайн
# Получаем полный URL аватара
avatar_url = None
if user.avatar:
request = self.context.get('request')
if request:
avatar_url = request.build_absolute_uri(user.avatar.url)
else:
avatar_url = user.avatar.url
return {
'id': user.id,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
'full_name': user.get_full_name() or user.email,
'avatar': avatar_url,
'role': user.role,
'is_online': is_online,
'last_activity': format_datetime_for_user(user.last_activity, request.user.timezone) if user.last_activity and request and request.user.is_authenticated else (user.last_activity.isoformat() if user.last_activity else None)
}
return None
class ChatCreateSerializer(serializers.ModelSerializer):
"""Сериализатор создания чата."""
participant_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
allow_empty=False
)
class Meta:
model = Chat
fields = ['chat_type', 'name', 'description', 'avatar', 'participant_ids']
def validate_participant_ids(self, value):
"""Валидация участников."""
from apps.users.models import User
# Проверяем что все пользователи существуют
users = User.objects.filter(id__in=value)
if users.count() != len(value):
raise serializers.ValidationError('Некоторые пользователи не найдены')
return value
def validate(self, attrs):
"""Валидация."""
# Для личного чата нужно ровно 2 участника (текущий + 1)
if attrs['chat_type'] == 'direct':
if len(attrs['participant_ids']) != 1:
raise serializers.ValidationError({
'participant_ids': 'Для личного чата нужен ровно 1 участник'
})
return attrs
def create(self, validated_data):
"""Создание чата."""
from apps.users.models import User
participant_ids = validated_data.pop('participant_ids')
user = self.context['request'].user
# Для личного чата проверяем что такой чат уже не существует
if validated_data['chat_type'] == 'direct':
existing_chat = Chat.objects.filter(
chat_type='direct',
participants__user=user
).filter(
participants__user_id=participant_ids[0]
).first()
if existing_chat:
return existing_chat
# Создаем чат
chat = Chat.objects.create(
created_by=user,
**validated_data
)
# Добавляем создателя как участника
ChatParticipant.objects.create(
chat=chat,
user=user,
role='admin'
)
# Добавляем остальных участников
# Оптимизация: используем bulk_create вместо цикла с create()
users = list(User.objects.filter(id__in=participant_ids))
participants_to_create = [
ChatParticipant(
chat=chat,
user=participant_user,
role='member'
)
for participant_user in users
]
if participants_to_create:
ChatParticipant.objects.bulk_create(participants_to_create)
return chat
class ChatDetailSerializer(ChatSerializer):
"""Детальный сериализатор чата (с участниками)."""
participants = ChatParticipantSerializer(many=True, read_only=True)
class Meta(ChatSerializer.Meta):
fields = ChatSerializer.Meta.fields + ['participants']