""" Сериализаторы для чата и сообщений. """ 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']