Corp/backend/communications/api/serializers.py

892 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
)