""" Serializers for Communications API """ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from django.contrib.auth import get_user_model from ..models import ( Chat, Message, MessageAttachment, MessageReaction, Poll, PollOption, ChatMembership, ChatUserSettings, ) from ..serialization import serialize_message User = get_user_model() def _get_active_memberships(obj, *, limit: int | None = None): if hasattr(obj, "_prefetched_objects_cache") and "memberships" in obj._prefetched_objects_cache: memberships = list(obj.memberships.all()) else: memberships = list( obj.memberships.filter(is_active=True).select_related("user") ) if limit is not None: memberships = memberships[:limit] return memberships def _get_last_message(obj): if hasattr(obj, "_prefetched_last_message"): prefetched = obj._prefetched_last_message return prefetched[0] if prefetched else None return ( obj.messages.filter(is_deleted=False) .select_related("author") .order_by("-created_at") .first() ) class ChatLastMessageSerializer(serializers.Serializer): id = serializers.IntegerField() content = serializers.CharField() author_name = serializers.CharField() created_at = serializers.DateTimeField() class ChatParticipantPreviewSerializer(serializers.Serializer): id = serializers.IntegerField() name = serializers.CharField() avatar = serializers.CharField(allow_null=True) class PollVoterSerializer(serializers.Serializer): voter__first_name = serializers.CharField() voter__last_name = serializers.CharField() voter__id = serializers.IntegerField() class ChatUserSettingsSerializer(serializers.ModelSerializer): """Настройки пользователя для чата""" class Meta: model = ChatUserSettings fields = ["is_pinned", "notifications_enabled"] class ChatMembershipSerializer(serializers.ModelSerializer): """Членство в чате""" user_name = serializers.CharField( source="user.get_full_name", read_only=True ) can_manage_members = serializers.BooleanField(read_only=True) class Meta: model = ChatMembership fields = [ "id", "user", "user_name", "role", "joined_at", "invited_by", "is_active", "left_at", "can_send_messages", "can_add_members", "can_remove_members", "can_pin_messages", "can_manage_members", ] read_only_fields = ["joined_at", "left_at"] class ChatListSerializer(serializers.ModelSerializer): """Сериализатор для списка чатов (облегченный)""" last_message = serializers.SerializerMethodField() unread_count = serializers.SerializerMethodField() participant_details = serializers.SerializerMethodField() participant_names = serializers.SerializerMethodField() member_ids = serializers.SerializerMethodField() is_pinned = serializers.SerializerMethodField() notifications_enabled = serializers.SerializerMethodField() last_read_message_id = serializers.SerializerMethodField() # NEW: context info context_type = serializers.CharField( source="context_content_type.model", read_only=True ) class Meta: model = Chat fields = [ "id", "type", "name", "description", "avatar", "created_at", # NEW: universal fields "context_object_id", "context_type", "flags", "extra_data", "include_all_users", # DEPRECATED: для обратной совместимости "is_main", # Messages & status "last_message", "unread_count", "participant_details", "participant_names", "member_ids", "is_pinned", "notifications_enabled", "last_read_message_id", ] @extend_schema_field(serializers.IntegerField()) def get_unread_count(self, obj): """ Количество непрочитанных из денормализованного поля. ОПТИМИЗИРОВАНО: Использует prefetch'нутый my_read_state вместо подзапроса. """ if hasattr(obj, "my_read_state") and obj.my_read_state: return obj.my_read_state[0].unread_count return 0 @extend_schema_field(ChatLastMessageSerializer) def get_last_message(self, obj): """Последнее сообщение в чате (использует prefetch если доступен)""" last_msg = _get_last_message(obj) if last_msg: return { "id": last_msg.id, "content": last_msg.content[:100], "author_name": last_msg.author.get_full_name() if last_msg.author else "Unknown", "created_at": last_msg.created_at.isoformat(), } return None @extend_schema_field(ChatParticipantPreviewSerializer(many=True)) def get_participant_details(self, obj): members = _get_active_memberships(obj, limit=20) return [ { "id": membership.user.id, "name": membership.user.get_full_name(), "avatar": membership.user.avatar.url if membership.user.avatar else None, } for membership in members ] @extend_schema_field(serializers.ListField(child=serializers.CharField())) def get_participant_names(self, obj): """Имена участников (для приватных чатов, из prefetch)""" if obj.type == "private": members = _get_active_memberships(obj, limit=5) return [membership.user.get_full_name() for membership in members] return [] @extend_schema_field( serializers.ListField(child=serializers.IntegerField()) ) def get_member_ids(self, obj): """ID участников (из prefetch, для поиска существующего чата)""" return [membership.user_id for membership in _get_active_memberships(obj)] @extend_schema_field(serializers.BooleanField()) def get_is_pinned(self, obj): """Закреплен ли чат для текущего пользователя (из prefetch)""" if hasattr(obj, "my_settings") and obj.my_settings: return obj.my_settings[0].is_pinned return False @extend_schema_field(serializers.BooleanField()) def get_notifications_enabled(self, obj): """Включены ли уведомления (из prefetch)""" if hasattr(obj, "my_settings") and obj.my_settings: return obj.my_settings[0].notifications_enabled return True @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_last_read_message_id(self, obj): """ID последнего прочитанного сообщения (из prefetch)""" if hasattr(obj, "my_read_state") and obj.my_read_state: return obj.my_read_state[0].last_read_message_id return None class ChatDetailSerializer(serializers.ModelSerializer): """Детальный сериализатор чата. MIGRATION: Убрали participants, используем только memberships. """ participant_details = serializers.SerializerMethodField() memberships = ChatMembershipSerializer(many=True, read_only=True) user_settings = serializers.SerializerMethodField() is_pinned = serializers.SerializerMethodField() notifications_enabled = serializers.SerializerMethodField() last_read_message_id = serializers.SerializerMethodField() last_message = serializers.SerializerMethodField() unread_count = serializers.SerializerMethodField() context_content_type = serializers.PrimaryKeyRelatedField( queryset=ContentType.objects.all(), write_only=True, required=False, allow_null=True, ) include_all_employees = serializers.BooleanField( write_only=True, required=False ) # NEW: context info context_type = serializers.CharField( source="context_content_type.model", read_only=True, allow_null=True ) context_app = serializers.CharField( source="context_content_type.app_label", read_only=True, allow_null=True ) class Meta: model = Chat fields = [ "id", "type", "name", "description", "avatar", "created_by", "created_at", # NEW: universal fields "context_content_type", "context_object_id", "context_type", "context_app", "flags", "extra_data", "include_all_users", "include_all_employees", # DEPRECATED: для обратной совместимости # (не удалять до полной миграции клиента) "is_main", # MIGRATION: Убрали 'participants', используем только memberships "participant_details", "memberships", "user_settings", "is_pinned", "notifications_enabled", "last_read_message_id", "last_message", "unread_count", ] read_only_fields = [ "created_at", "created_by", "context_type", "context_app", ] def create(self, validated_data): include_all_employees = validated_data.pop( "include_all_employees", None ) if include_all_employees is not None: validated_data["include_all_users"] = include_all_employees return super().create(validated_data) def update(self, instance, validated_data): include_all_employees = validated_data.pop( "include_all_employees", None ) if include_all_employees is not None: validated_data["include_all_users"] = include_all_employees return super().update(instance, validated_data) @extend_schema_field(ChatParticipantPreviewSerializer(many=True)) def get_participant_details(self, obj): """Детали участников MIGRATION: Используем memberships вместо participants """ return [ { "id": membership.user.id, "name": membership.user.get_full_name(), "avatar": membership.user.avatar.url if membership.user.avatar else None, } for membership in _get_active_memberships(obj, limit=20) ] @extend_schema_field(ChatUserSettingsSerializer) def get_user_settings(self, obj): """Настройки текущего пользователя для чата (из prefetch)""" if hasattr(obj, "my_settings") and obj.my_settings: return ChatUserSettingsSerializer(obj.my_settings[0]).data return {"is_pinned": False, "notifications_enabled": True} @extend_schema_field(serializers.BooleanField()) def get_is_pinned(self, obj): """Закреплен ли чат для текущего пользователя (из prefetch)""" if hasattr(obj, "my_settings") and obj.my_settings: return obj.my_settings[0].is_pinned return False @extend_schema_field(serializers.BooleanField()) def get_notifications_enabled(self, obj): """Включены ли уведомления для текущего пользователя (из prefetch)""" if hasattr(obj, "my_settings") and obj.my_settings: return obj.my_settings[0].notifications_enabled return True @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_last_read_message_id(self, obj): """ID последнего прочитанного сообщения (из prefetch)""" if hasattr(obj, "my_read_state") and obj.my_read_state: return obj.my_read_state[0].last_read_message_id return None @extend_schema_field(ChatLastMessageSerializer) def get_last_message(self, obj): last_msg = _get_last_message(obj) if not last_msg: return None return { "id": last_msg.id, "content": last_msg.content[:100], "author_name": last_msg.author.get_full_name() if last_msg.author else "Unknown", "created_at": last_msg.created_at.isoformat(), } @extend_schema_field(serializers.IntegerField()) def get_unread_count(self, obj): if hasattr(obj, "my_read_state") and obj.my_read_state: return obj.my_read_state[0].unread_count return 0 class MessageAttachmentSerializer(serializers.ModelSerializer): """Вложение сообщения""" class Meta: model = MessageAttachment fields = [ "id", "file_name", "file_type", "file_size", "mime_type", "file", "thumbnail", "uploaded_at", "width", "height", ] read_only_fields = ["uploaded_at", "width", "height"] class MessageReactionSerializer(serializers.ModelSerializer): """Реакция на сообщение""" user_name = serializers.CharField( source="user.get_full_name", read_only=True ) class Meta: model = MessageReaction fields = ["id", "emoji", "user", "user_name", "created_at"] read_only_fields = ["created_at"] class PollOptionSerializer(serializers.ModelSerializer): """Опция голосования""" percentage = serializers.SerializerMethodField() voters = serializers.SerializerMethodField() class Meta: model = PollOption fields = [ "id", "text", "position", "vote_count", "percentage", "voters", ] @extend_schema_field(serializers.FloatField()) def get_percentage(self, obj): """Процент голосов""" if obj.poll and obj.poll.total_voters > 0: return round((obj.vote_count / obj.poll.total_voters) * 100, 1) return 0 @extend_schema_field(PollVoterSerializer(many=True)) def get_voters(self, obj): """Список проголосовавших (если не анонимное)""" if obj.poll and obj.poll.is_anonymous: return [] # Возвращаем список с именами votes = obj.votes.select_related("voter").all() return [ { "voter__first_name": vote.voter.first_name, "voter__last_name": vote.voter.last_name, "voter__id": vote.voter.id, } for vote in votes ] class PollSerializer(serializers.ModelSerializer): """Голосование""" options = PollOptionSerializer(many=True, read_only=True) user_voted_option_ids = serializers.SerializerMethodField() # Поля для создания (write_only) options_data = serializers.ListField( child=serializers.CharField(max_length=200), write_only=True, required=False, help_text="Список вариантов ответов (строки)", ) chat_id = serializers.IntegerField(write_only=True, required=True) correct_option_index = serializers.IntegerField( write_only=True, required=False, allow_null=True ) closes_in_minutes = serializers.IntegerField( write_only=True, required=False, allow_null=True ) class Meta: model = Poll fields = [ "id", "message", "question", "is_anonymous", "is_multiple_choice", "is_quiz", "is_closed", "closes_at", "allows_custom_answers", "total_voters", "options", "user_voted_option_ids", # write_only поля "options_data", "chat_id", "correct_option_index", "closes_in_minutes", ] read_only_fields = ["id", "message", "is_closed", "total_voters"] @extend_schema_field( serializers.ListField(child=serializers.IntegerField()) ) def get_user_voted_option_ids(self, obj): """ID опций за которые проголосовал текущий пользователь""" request = self.context.get("request") if request and request.user.is_authenticated: votes = obj.votes.filter(voter=request.user) return [v.option_id for v in votes] return [] def create(self, validated_data): """Создание голосования с опциями""" from communications.models import Chat, Message from datetime import timedelta from django.utils import timezone import logging logger = logging.getLogger(__name__) logger.info(f"[PollSerializer.create] validated_data: {validated_data}") # Извлекаем данные для создания options_data = validated_data.pop("options_data", []) chat_id = validated_data.pop("chat_id") correct_option_index = validated_data.pop("correct_option_index", None) closes_in_minutes = validated_data.pop("closes_in_minutes", None) logger.info( f"[PollSerializer.create] options_data: {options_data}, " f"chat_id: {chat_id}" ) # Получаем автора (может быть передан через perform_create или взять из # запроса) author = ( validated_data.pop("author", None) or self.context.get("request").user ) # Создаем сообщение в чате try: chat = Chat.objects.get(id=chat_id) except Chat.DoesNotExist: logger.error(f"[PollSerializer.create] Chat {chat_id} not found") raise serializers.ValidationError({"chat_id": "Chat not found"}) message = Message.objects.create( chat=chat, author=author, content=f"📊 {validated_data['question']}", is_system=False, ) # Устанавливаем closes_at если указано if closes_in_minutes and closes_in_minutes > 0: validated_data["closes_at"] = timezone.now() + timedelta( minutes=closes_in_minutes ) # Создаем голосование poll = Poll.objects.create( message=message, author=author, **validated_data ) # Создаем опции for index, option_text in enumerate(options_data): is_correct = ( correct_option_index is not None and index == correct_option_index ) poll.options.create( text=option_text, position=index, is_correct=is_correct ) # Отправляем WebSocket уведомление о новом сообщении from channels.layers import get_channel_layer from asgiref.sync import async_to_sync from communications.serialization import serialize_message # Перезагружаем сообщение с poll и options для полной сериализации message.refresh_from_db() message_data = serialize_message(message) channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( f"chat_{chat.id}", { "type": "chat_message", "chat_id": chat.id, "payload": message_data, }, ) logger.info( "[PollSerializer.create] WebSocket notification sent " f"for message {message.id}" ) return poll class MessageListSerializer(serializers.ModelSerializer): """Облегченный сериализатор для списка сообщений""" author_name = serializers.CharField( source="author.get_full_name", read_only=True ) author_avatar = serializers.SerializerMethodField() reactions_summary = serializers.SerializerMethodField() class Meta: model = Message fields = [ "id", "content", "author", "author_name", "author_avatar", "created_at", "is_edited", "edited_at", "is_deleted", "is_pinned", "has_attachments", "reactions_summary", ] @extend_schema_field(serializers.CharField(allow_null=True)) def get_author_avatar(self, obj): if obj.author and obj.author.avatar: return obj.author.avatar.url return None @extend_schema_field(OpenApiTypes.OBJECT) def get_reactions_summary(self, obj): """Суммарная информация о реакциях""" reactions = {} for reaction in obj.reactions.all(): emoji = reaction.emoji if emoji not in reactions: reactions[emoji] = {"count": 0, "users": [], "user_names": []} reactions[emoji]["count"] += 1 reactions[emoji]["users"].append(reaction.user_id) reactions[emoji]["user_names"].append(reaction.user.get_full_name()) return reactions class MessageDetailSerializer(serializers.ModelSerializer): """Детальный сериализатор сообщения. Использует функцию serialize_message. """ class Meta: model = Message fields = "__all__" def to_representation(self, instance): """Используем существующую функцию сериализации""" return serialize_message(instance) class MessageCreateSerializer(serializers.ModelSerializer): """Сериализатор для создания/редактирования сообщений""" class Meta: model = Message fields = ["content", "chat", "reply_to", "is_forwarded"] def validate(self, attrs): """Валидация: сообщение должно иметь либо текст, либо вложения""" # Проверка контента будет на уровне view при наличии файлов return attrs def create(self, validated_data): """Создание сообщения с автором""" validated_data["author"] = self.context["request"].user return super().create(validated_data) class MessageEditSerializer(serializers.Serializer): """Сериализатор для редактирования сообщения""" content = serializers.CharField(required=False, allow_blank=True) existing_attachment_ids = serializers.ListField( child=serializers.IntegerField(), required=False, allow_empty=True ) class ReactionSerializer(serializers.Serializer): """Сериализатор для добавления/удаления реакции""" emoji = serializers.CharField(max_length=10) class ForwardMessageSerializer(serializers.Serializer): """Сериализатор для пересылки сообщений""" message_ids = serializers.ListField( child=serializers.IntegerField(), min_length=1 ) target_chat_id = serializers.IntegerField() class BulkDeleteSerializer(serializers.Serializer): """Сериализатор для массового удаления""" message_ids = serializers.ListField( child=serializers.IntegerField(), min_length=1 )