Corp/backend/communications/api/serializers.py
lizerk 80cdc36d92 fix: исправлена система прав доступа к чатам и сообщениям
Backend:
- Исправлен user_can_access_chat() для поддержки context-based чатов
- Обновлен is_chat_member() для использования get_participants()
- Добавлена проверка has_send_messages_permission (блокирует гостей)
- Исправлен MessageViewSet.get_queryset() для комментариев
- Добавлена поддержка Feed Post resolver в resolve_chat_participants()
- Чаты типа 'comments' исключены из общего списка
- Добавлены permissions для mark_read, messages, messages_around
- Добавлена команда manage_chat_permissions для управления ролями

Frontend:
- Скрыто поле ввода сообщений для гостей (role='guest')
- Добавлена проверка can_send_messages из membership
- Показывается информативная заглушка для пользователей без прав

Tests & Docs:
- Добавлены тесты для guest роли (send_message + reactions)
- Документация по ролям и правам в чатах
- Troubleshooting guide для отладки permissions

Fixes:
- 403 при доступе к чатам-комментариям
- 404 при удалении/редактировании комментариев
- Комментарии в общем списке чатов
- Гости могли отправлять сообщения
2026-03-12 14:30:25 +03:00

452 lines
18 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 rest_framework import serializers
from django.contrib.auth import get_user_model
from ..models import (
Chat, Message, MessageAttachment,
MessageReaction, Poll, PollOption, PollVote,
ChatMembership, ChatUserSettings
)
from ..serialization import serialize_message
User = get_user_model()
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_names = 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_names',
'is_pinned', 'notifications_enabled', 'last_read_message_id'
]
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
def get_last_message(self, obj):
"""Последнее сообщение в чате"""
last_msg = obj.messages.filter(is_deleted=False).order_by('-created_at').first()
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
def get_participant_names(self, obj):
"""Имена участников (для приватных чатов)"""
if obj.type == 'private':
return [p.get_full_name() for p in obj.participants.all()[:5]]
return []
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
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
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):
"""Детальный сериализатор чата"""
participants = serializers.PrimaryKeyRelatedField(
many=True,
queryset=User.objects.all(),
required=False
)
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()
# 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_object_id', 'context_type', 'context_app', 'flags', 'extra_data', 'include_all_users',
# DEPRECATED: для обратной совместимости (не удалять до полной миграции клиента)
'is_main',
# Participants & memberships
'participants', 'participant_details',
'memberships', 'user_settings', 'is_pinned',
'notifications_enabled', 'last_read_message_id'
]
read_only_fields = ['created_at', 'created_by', 'context_type', 'context_app']
def get_participant_details(self, obj):
"""Детали участников (свежие данные из БД)"""
# Используем прямой запрос в обход prefetch cache
from django.contrib.auth import get_user_model
User = get_user_model()
participants = User.objects.filter(
chats=obj
).distinct()[:20]
return [{
'id': p.id,
'name': p.get_full_name(),
'avatar': p.avatar.url if p.avatar else None
} for p in participants]
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}
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
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
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 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']
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
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']
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}, 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(f"[PollSerializer.create] WebSocket notification sent 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'
]
def get_author_avatar(self, obj):
if obj.author and obj.author.avatar:
return obj.author.avatar.url
return None
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):
"""Валидация: сообщение должно иметь либо текст, либо вложения"""
content = attrs.get('content', '').strip()
# Проверка контента будет на уровне 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
)