АРХИТЕКТУРНЫЙ РЕФАКТОРИНГ: ✅ Убрано дублирование логики Chat.participants vs ChatMembership.is_active ✅ Теперь единственный источник истины - ChatMembership.is_active ✅ Исправлен баг с изменением ролей (роли не отображались после изменения) ИЗМЕНЕНИЯ: Backend (viewsets.py): - get_queryset: мигрировано на memberships__is_active=True - create: убрано participants.add, только memberships - leave/remove_member: только is_active=False + left_at - add_member: восстанавливает is_active=True если был False - change_role: явно устанавливает is_active=True + left_at=None Backend (serializers.py): - Убрано поле participants из ChatDetailSerializer - get_participant_details: через memberships.filter(is_active=True) - get_participant_names: через memberships Backend (models.py): - get_participants(): только через ChatMembership.is_active=True - __str__(): получаем участников через memberships - participants помечен как устаревший (в комментариях) Backend (rules.py): - is_member_of_chat: только ChatMembership.is_active=True - has_send_messages_permission: убран fallback на participants Миграции: - Создана 0038: убирает 'owner' из choices, очищает help_text - Исправлены зависимости в requests_app/documents/feed 0010/0014/0006 (заменили __latest__ на конкретную версию 0037) - Создан management command migrate_participants_to_memberships (перенос данных participants → memberships) Frontend: - Добавлено оптимистичное обновление UI при изменении ролей - Детальное логирование для диагностики Тесты: - test_change_role_improved.py: проверка изменения ролей с задержкой - Обновлен test_chat_roles.py Удаление устаревшей документации: - Удалены старые отчеты из docs/ (73 файла) - Сохранены актуальные руководства ИСПРАВЛЕННЫЙ БАГ: До: При изменении роли is_active оставался False → membership не попадал в filter(is_active=True) → исчезал с фронтенда После: Явно устанавливаем is_active=True при любом действии с членством
452 lines
18 KiB
Python
452 lines
18 KiB
Python
"""
|
||
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):
|
||
"""Имена участников (для приватных чатов)
|
||
MIGRATION: Используем memberships вместо participants
|
||
"""
|
||
if obj.type == 'private':
|
||
active_members = obj.memberships.filter(is_active=True).select_related('user')[:5]
|
||
return [m.user.get_full_name() for m in active_members]
|
||
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):
|
||
"""Детальный сериализатор чата
|
||
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()
|
||
|
||
# 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',
|
||
# MIGRATION: Убрали 'participants', используем только memberships
|
||
'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):
|
||
"""Детали участников
|
||
MIGRATION: Используем memberships вместо participants
|
||
"""
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
|
||
# Получаем активных участников через memberships
|
||
active_memberships = obj.memberships.filter(is_active=True).select_related('user')[:20]
|
||
|
||
return [{
|
||
'id': m.user.id,
|
||
'name': m.user.get_full_name(),
|
||
'avatar': m.user.avatar.url if m.user.avatar else None
|
||
} for m in active_memberships]
|
||
|
||
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
|
||
)
|