700 lines
24 KiB
Python
700 lines
24 KiB
Python
"""
|
||
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):
|
||
"""Суммарная информация о реакциях"""
|
||
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):
|
||
"""Сериализатор для создания/редактирования сообщений"""
|
||
|
||
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
|
||
)
|