""" Serializers for Communications API """ from datetime import timedelta import rules from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.utils import timezone from rest_framework import serializers from ..models import ( Chat, Message, MessageAttachment, MessageReaction, Poll, PollOption, ChatMembership, ChatUserSettings, ) from ..mentions import build_message_mentions from ..serialization import serialize_message, serialize_poll from ..utils import user_can_access_chat 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, user__is_active=True).select_related( "user" ) ) memberships = [ membership for membership in memberships if membership.is_active and membership.user.is_active ] 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", "poll") .prefetch_related("attachments") .order_by("-created_at") .first() ) def _get_last_message_preview(message): content = (message.content or "").strip() if content: return content[:100], "text" poll = getattr(message, "poll", None) if poll is not None: question = (poll.question or "").strip() preview = f"Голосование: {question}" if question else "Голосование" return preview[:100], "poll" attachments = list(message.attachments.all()) if len(attachments) > 1: return f"Вложения: {len(attachments)}", "attachments" if attachments: attachment_type = attachments[0].file_type labels = { "image": "Фото", "video": "Видео", "audio": "Аудио", "file": "Файл", } return labels.get(attachment_type, "Файл"), attachment_type or "file" return "Сообщение", "message" def _serialize_last_message(message): preview_text, preview_type = _get_last_message_preview(message) return { "id": message.id, "content": (message.content or "")[:100], "preview_text": preview_text, "preview_type": preview_type, "author_name": ( message.author.get_full_name() if message.author else "Unknown" ), "created_at": message.created_at.isoformat(), } class ChatLastMessageSerializer(serializers.Serializer): id = serializers.IntegerField() content = serializers.CharField(allow_blank=True) preview_text = serializers.CharField() preview_type = 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): """Настройки пользователя для чата""" notifications_enabled = serializers.SerializerMethodField() effective_notification_mode = serializers.SerializerMethodField() class Meta: model = ChatUserSettings fields = [ "is_pinned", "notifications_enabled", "notification_mode", "effective_notification_mode", "muted_until", "show_notification_preview", "notification_sound_enabled", ] def get_notifications_enabled(self, obj): return ( obj.effective_notification_mode() == ChatUserSettings.NotificationMode.ALL ) def get_effective_notification_mode(self, obj): return obj.effective_notification_mode() class ChatNotificationPreferencesUpdateSerializer(serializers.Serializer): notification_mode = serializers.ChoiceField( choices=ChatUserSettings.NotificationMode.choices, required=False, ) mute_for_seconds = serializers.IntegerField( min_value=0, max_value=31_536_000, required=False, ) muted_until = serializers.DateTimeField( allow_null=True, required=False, ) show_notification_preview = serializers.BooleanField(required=False) notification_sound_enabled = serializers.BooleanField(required=False) 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() has_unread_mention = 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", "has_unread_mention", "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(serializers.BooleanField()) def get_has_unread_mention(self, obj): if hasattr(obj, "my_read_state") and obj.my_read_state: return obj.my_read_state[0].unread_mention_count > 0 return False @extend_schema_field(ChatLastMessageSerializer) def get_last_message(self, obj): """Последнее сообщение в чате (использует prefetch если доступен)""" last_msg = _get_last_message(obj) if last_msg: return _serialize_last_message(last_msg) 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].effective_notification_mode() == ChatUserSettings.NotificationMode.ALL ) 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() has_unread_mention = 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", "has_unread_mention", ] 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, "notification_mode": ChatUserSettings.NotificationMode.ALL, "effective_notification_mode": ChatUserSettings.NotificationMode.ALL, "muted_until": None, "show_notification_preview": True, "notification_sound_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].effective_notification_mode() == ChatUserSettings.NotificationMode.ALL ) 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 _serialize_last_message(last_msg) @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 @extend_schema_field(serializers.BooleanField()) def get_has_unread_mention(self, obj): if hasattr(obj, "my_read_state") and obj.my_read_state: return obj.my_read_state[0].unread_mention_count > 0 return False 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=True, min_length=2, max_length=10, help_text="Список вариантов ответов (строки)", ) chat_id = serializers.IntegerField(write_only=True, required=True) reply_to_id = serializers.IntegerField(write_only=True, required=False) 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, min_value=1, max_value=525_600, ) 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", "reply_to_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 to_representation(self, instance): request = self.context.get("request") user = request.user if request and request.user.is_authenticated else None return serialize_poll(instance, user=user) def validate_question(self, value): question = value.strip() if not question: raise serializers.ValidationError("Введите вопрос") return question def validate(self, attrs): request = self.context.get("request") if not request or not request.user.is_authenticated: raise serializers.ValidationError("Требуется авторизация") try: chat = Chat.objects.get(pk=attrs["chat_id"]) except Chat.DoesNotExist: raise serializers.ValidationError( {"chat_id": "Чат не найден"} ) from None if not user_can_access_chat(chat, request.user): raise serializers.ValidationError( {"chat_id": "Нет доступа к этому чату"} ) if not rules.test_rule( "communications.send_message", request.user, chat, ): raise serializers.ValidationError( {"chat_id": "Нет права отправлять сообщения в этот чат"} ) normalized_options = [option.strip() for option in attrs["options_data"]] if any(not option for option in normalized_options): raise serializers.ValidationError( {"options_data": "Варианты не могут быть пустыми"} ) if len({option.casefold() for option in normalized_options}) != len( normalized_options ): raise serializers.ValidationError( {"options_data": "Варианты не должны повторяться"} ) attrs["options_data"] = normalized_options correct_option_index = attrs.get("correct_option_index") if attrs.get("is_quiz"): if attrs.get("is_multiple_choice"): raise serializers.ValidationError( {"is_multiple_choice": "Викторина допускает только один ответ"} ) if correct_option_index is None or not ( 0 <= correct_option_index < len(normalized_options) ): raise serializers.ValidationError( {"correct_option_index": "Выберите правильный вариант"} ) elif correct_option_index is not None: raise serializers.ValidationError( {"correct_option_index": "Правильный ответ доступен только для викторины"} ) if attrs.get("allows_custom_answers"): raise serializers.ValidationError( {"allows_custom_answers": "Свои варианты ответа пока не поддерживаются"} ) self._reply_to = None reply_to_id = attrs.get("reply_to_id") if reply_to_id is not None: try: self._reply_to = chat.messages.get( pk=reply_to_id, is_deleted=False, ) except Message.DoesNotExist: raise serializers.ValidationError( {"reply_to_id": "Исходное сообщение не найдено"} ) from None self._poll_chat = chat return attrs def create(self, validated_data): """Создание голосования с опциями""" options_data = validated_data.pop("options_data") validated_data.pop("chat_id") validated_data.pop("reply_to_id", None) correct_option_index = validated_data.pop("correct_option_index", None) closes_in_minutes = validated_data.pop("closes_in_minutes", None) author = ( validated_data.pop("author", None) or self.context.get("request").user ) if closes_in_minutes: validated_data["closes_at"] = timezone.now() + timedelta( minutes=closes_in_minutes ) with transaction.atomic(): message = Message.objects.create( chat=self._poll_chat, author=author, content=validated_data["question"], reply_to=self._reply_to, is_system=False, ) poll = Poll.objects.create( message=message, author=author, **validated_data, ) PollOption.objects.bulk_create( [ PollOption( poll=poll, text=option_text, position=index, is_correct=(index == correct_option_index), ) for index, option_text in enumerate(options_data) ] ) 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", "mentions", "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): """Суммарная информация о реакциях""" return obj.get_reactions_summary() 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): """Сериализатор для создания/редактирования сообщений""" mentioned_user_ids = serializers.ListField( child=serializers.IntegerField(min_value=1), write_only=True, required=False, allow_empty=True, max_length=50, ) class Meta: model = Message fields = [ "content", "chat", "reply_to", "is_forwarded", "mentioned_user_ids", ] def validate(self, attrs): """Валидация: сообщение должно иметь либо текст, либо вложения""" # Проверка контента будет на уровне view при наличии файлов return attrs def create(self, validated_data): """Создание сообщения с автором""" author = self.context["request"].user mentioned_user_ids = validated_data.pop("mentioned_user_ids", []) validated_data["author"] = author validated_data["mentions"] = build_message_mentions( validated_data["chat"], author, validated_data.get("content", ""), mentioned_user_ids, ) 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 ) mentioned_user_ids = serializers.ListField( child=serializers.IntegerField(min_value=1), required=False, allow_empty=True, max_length=50, ) 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 )