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