581 lines
26 KiB
Python
581 lines
26 KiB
Python
"""
|
||
Сериализаторы для чата и сообщений.
|
||
"""
|
||
from rest_framework import serializers
|
||
from django.db import models
|
||
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
|
||
from .services import ChatService
|
||
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
|
||
|
||
# Для личного чата используем сервис с защитой от race condition
|
||
if validated_data['chat_type'] == 'direct':
|
||
other_user = User.objects.get(id=participant_ids[0])
|
||
chat, _ = ChatService.get_or_create_direct_chat(
|
||
user1=user,
|
||
user2=other_user,
|
||
created_by=user
|
||
)
|
||
return chat
|
||
|
||
# Для группового чата - обычная логика
|
||
chat = Chat.objects.create(
|
||
created_by=user,
|
||
**validated_data
|
||
)
|
||
|
||
# Добавляем создателя как участника
|
||
ChatParticipant.objects.create(
|
||
chat=chat,
|
||
user=user,
|
||
role='admin'
|
||
)
|
||
|
||
# Добавляем остальных участников
|
||
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']
|