diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index 584a222b..b4245c0c 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -2,11 +2,17 @@ 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.contenttypes.models import ContentType -from rest_framework import serializers 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, @@ -17,7 +23,8 @@ from ..models import ( ChatMembership, ChatUserSettings, ) -from ..serialization import serialize_message +from ..serialization import serialize_message, serialize_poll +from ..utils import user_can_access_chat User = get_user_model() @@ -461,15 +468,22 @@ class PollSerializer(serializers.ModelSerializer): options_data = serializers.ListField( child=serializers.CharField(max_length=200), write_only=True, - required=False, + 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 + write_only=True, + required=False, + allow_null=True, + min_value=1, + max_value=525_600, ) class Meta: @@ -490,6 +504,7 @@ class PollSerializer(serializers.ModelSerializer): # write_only поля "options_data", "chat_id", + "reply_to_id", "correct_option_index", "closes_in_minutes", ] @@ -506,92 +521,134 @@ class PollSerializer(serializers.ModelSerializer): 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): """Создание голосования с опциями""" - 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") + 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) - - 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: + if closes_in_minutes: 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 + 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.options.create( - text=option_text, position=index, is_correct=is_correct + 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) + ] ) - - # Отправляем 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 diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 60c264ac..9c885f05 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -51,10 +51,14 @@ from ..models import ( MessageAttachment, MessageReaction, Poll, - PollVote, ) +from ..polls import PollActionError, cast_poll_vote from ..utils import _coerce_ts, user_can_access_chat -from communications.serialization import serialize_message +from communications.serialization import ( + serialize_message, + serialize_message_reader, + serialize_poll, +) logger = logging.getLogger(__name__) User = get_user_model() @@ -612,7 +616,7 @@ class ChatViewSet(viewsets.ModelViewSet): user.id}, chat={ chat.id}, msg={ last_message.id}") - self._send_marked_read_event(user.id, chat.id, last_message.id) + self._send_marked_read_event(user, chat.id, last_message.id) return # Защита от откатов: только если НОВЕЕ @@ -636,19 +640,21 @@ class ChatViewSet(viewsets.ModelViewSet): user.id}, chat={ chat.id}, msg={ last_message.id}") - self._send_marked_read_event(user.id, chat.id, last_message.id) + self._send_marked_read_event(user, chat.id, last_message.id) - def _send_marked_read_event(self, user_id, chat_id, message_id): + def _send_marked_read_event(self, user, chat_id, message_id): """WebSocket событие для синхронизации и read receipts.""" channel_layer = get_channel_layer() + reader = serialize_message_reader(user) event_payload = { 'chat_id': chat_id, 'last_read_message_id': message_id, - 'reader_user_id': user_id, + 'reader_user_id': user.id, + 'reader': reader, } async_to_sync(channel_layer.group_send)( - f'user_{user_id}', + f'user_{user.id}', { 'type': 'chat_marked_read_sync', **event_payload, @@ -1724,80 +1730,97 @@ class PollViewSet(viewsets.ModelViewSet): - results: GET /api/v1/communications/polls/{id}/results/ - результаты """ - queryset = Poll.objects.all() + queryset = Poll.objects.select_related( + 'message', + 'message__chat', + 'author', + ).prefetch_related('options') serializer_class = PollSerializer permission_classes = [IsAuthenticated] + http_method_names = ['get', 'post', 'head', 'options'] - def perform_create(self, serializer): - """Автоматически устанавливаем автора голосования""" - serializer.save(author=self.request.user) + def get_queryset(self): + queryset = super().get_queryset() + user = self.request.user + if user.is_superuser: + return queryset - @action(detail=True, methods=['post']) - def vote(self, request, pk=None): - """Проголосовать""" - poll = self.get_object() - - if poll.is_closed: - return Response( - {'error': 'Poll is closed'}, - status=status.HTTP_400_BAD_REQUEST + accessible_filter = ( + Q(message__chat__type='global') + | Q(message__chat__participants=user) + | Q( + message__chat__memberships__user=user, + message__chat__memberships__is_active=True, ) + | Q(message__chat__include_all_users=True) + ) - option_ids = request.data.get('option_ids', []) - if not option_ids: - return Response( - {'error': 'No options selected'}, - status=status.HTTP_400_BAD_REQUEST - ) + context_chat_ids = [ + chat.id + for chat in Chat.objects.filter( + context_object_id__isnull=False, + ).select_related('context_content_type') + if user_can_access_chat(chat, user) + ] + if context_chat_ids: + accessible_filter |= Q(message__chat_id__in=context_chat_ids) - # Проверка что опции существуют и принадлежат этому poll - try: - options = poll.options.filter(pk__in=option_ids) - if options.count() != len(option_ids): - return Response( - {'error': 'Invalid option IDs'}, - status=status.HTTP_400_BAD_REQUEST - ) - except (ValueError, TypeError): - return Response( - {'error': 'Invalid option IDs format'}, - status=status.HTTP_400_BAD_REQUEST - ) + return queryset.filter(accessible_filter).distinct() - # Проверка множественного выбора - if not poll.is_multiple_choice and len(option_ids) > 1: - return Response( - {'error': 'Only one option allowed'}, - status=status.HTTP_400_BAD_REQUEST - ) + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + poll = serializer.save(author=request.user) - with transaction.atomic(): - # Удаляем ВСЕ старые голоса пользователя (и для single и для multiple - # choice) - PollVote.objects.filter( - poll=poll, - voter=request.user - ).delete() + message = Message.objects.select_related( + 'author', + 'reply_to', + 'reply_to__author', + 'poll', + 'chat', + ).prefetch_related( + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), + ).get(pk=poll.message_id) - # Добавляем новые голоса - for option in options: - PollVote.objects.create( - poll=poll, - option=option, - voter=request.user - ) + websocket_payload = serialize_message(message) + response_message = serialize_message( + message, + user=request.user, + include_linked_tasks=True, + ) + async_to_sync(get_channel_layer().group_send)( + f'chat_{message.chat_id}', + { + 'type': 'chat.message', + 'chat_id': message.chat_id, + 'payload': websocket_payload, + }, + ) - # Пересчитываем голоса - for option in poll.options.all(): - option.vote_count = option.votes.count() - option.save() + return Response( + { + 'poll': serialize_poll(poll, user=request.user), + 'message': response_message, + }, + status=status.HTTP_201_CREATED, + ) - poll.total_voters = PollVote.objects.filter( - poll=poll).values('voter').distinct().count() - poll.save() - - # WebSocket уведомление + def _broadcast_update( + self, + poll, + *, + voter_user_id=None, + selected_option_ids=None, + ): channel_layer = get_channel_layer() + results = serialize_poll(poll) async_to_sync(channel_layer.group_send)( f'chat_{poll.message.chat_id}', { @@ -1806,35 +1829,75 @@ class PollViewSet(viewsets.ModelViewSet): 'payload': { 'poll_id': poll.id, 'message_id': poll.message_id, - 'total_voters': poll.total_voters - } - } + 'results': results, + }, + }, ) - # Возвращаем полные результаты - results_serializer = PollSerializer(poll, context={'request': request}) - return Response({'ok': True, 'results': results_serializer.data}) + if voter_user_id is not None: + async_to_sync(channel_layer.group_send)( + f'user_{voter_user_id}', + { + 'type': 'poll.update.sync', + 'chat_id': poll.message.chat_id, + 'payload': { + 'poll_id': poll.id, + 'message_id': poll.message_id, + 'results': results, + 'voter_user_id': voter_user_id, + 'selected_option_ids': selected_option_ids or [], + }, + }, + ) + + @action(detail=True, methods=['post']) + def vote(self, request, pk=None): + """Проголосовать""" + poll = self.get_object() + option_ids = request.data.get('option_ids', []) + try: + poll, selected_option_ids = cast_poll_vote( + poll, + request.user, + option_ids, + ) + except PollActionError as exc: + return Response( + {'error': exc.message}, + status=exc.status_code, + ) + + self._broadcast_update( + poll, + voter_user_id=request.user.id, + selected_option_ids=selected_option_ids, + ) + + return Response( + { + 'ok': True, + 'results': serialize_poll(poll, user=request.user), + } + ) @action(detail=True, methods=['post']) def close(self, request, pk=None): """Закрыть голосование""" poll = self.get_object() - # Проверка прав (только автор сообщения) - if poll.message.author != request.user: + if poll.author != request.user and not request.user.is_superuser: return Response( - {'error': 'Only poll author can close it'}, + {'error': 'Завершить голосование может только автор'}, status=status.HTTP_403_FORBIDDEN ) - # Используем метод модели для установки is_closed и closed_at poll.close() + self._broadcast_update(poll) - return Response(PollSerializer(poll, context={'request': request}).data) + return Response(serialize_poll(poll, user=request.user)) @action(detail=True, methods=['get', 'post']) def results(self, request, pk=None): """Получить результаты голосования""" poll = self.get_object() - serializer = PollSerializer(poll, context={'request': request}) - return Response(serializer.data) + return Response(serialize_poll(poll, user=request.user)) diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 8fbcb48d..bbf339b4 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -16,8 +16,16 @@ from channels.db import database_sync_to_async from django.db.models import Prefetch, Q from django.utils import timezone -from .models import Chat, ChatMembership, ChatReadState, Message, MessageReaction -from .serialization import serialize_message +from .models import ( + Chat, + ChatMembership, + ChatReadState, + Message, + MessageReaction, + Poll, +) +from .polls import PollActionError, cast_poll_vote +from .serialization import serialize_message, serialize_poll from .utils import user_can_access_chat logger = logging.getLogger(__name__) @@ -297,6 +305,11 @@ class ChatConsumerMixin: "poll_id": payload.get("poll_id"), "message_id": payload.get("message_id"), "results": payload.get("results"), + "voter_user_id": payload.get("voter_user_id"), + "selected_option_ids": payload.get( + "selected_option_ids", + [], + ), } ) @@ -304,6 +317,7 @@ class ChatConsumerMixin: chat_id = event.get("chat_id") last_read_message_id = event.get("last_read_message_id") reader_user_id = event.get("reader_user_id") + reader = event.get("reader") if chat_id != self.active_chat_id: return @@ -323,6 +337,7 @@ class ChatConsumerMixin: "chat_id": chat_id, "last_read_message_id": last_read_message_id, "reader_user_id": reader_user_id, + "reader": reader, } ) @@ -565,8 +580,60 @@ class ChatConsumerMixin: async def _handle_vote_poll(self, content): """Голосование в опросе""" - # TODO: Реализовать логику голосования - pass + poll_id = content.get("poll_id") + option_ids = content.get("option_ids") + if not self.active_chat_id or not poll_id or not isinstance(option_ids, list): + await self.send_json( + { + "type": "error", + "code": "poll_vote_failed", + "error": "Некорректные данные голосования", + } + ) + return + + result = await self._cast_poll_vote( + poll_id, + option_ids, + self.active_chat_id, + self.user, + ) + if not result["ok"]: + await self.send_json( + { + "type": "error", + "code": "poll_vote_failed", + "error": result["error"], + } + ) + return + + await self.channel_layer.group_send( + f"chat_{self.active_chat_id}", + { + "type": "chat_poll_update", + "chat_id": self.active_chat_id, + "payload": { + "poll_id": result["poll_id"], + "message_id": result["message_id"], + "results": result["results"], + }, + }, + ) + await self.channel_layer.group_send( + f"user_{self.user.id}", + { + "type": "poll_update_sync", + "chat_id": self.active_chat_id, + "payload": { + "poll_id": result["poll_id"], + "message_id": result["message_id"], + "results": result["results"], + "voter_user_id": self.user.id, + "selected_option_ids": result["selected_option_ids"], + }, + }, + ) # ==================== Вспомогательные методы ==================== @@ -695,6 +762,33 @@ class ChatConsumerMixin: ).delete() return deleted_count > 0 + @database_sync_to_async + def _cast_poll_vote(self, poll_id, option_ids, chat_id, user): + try: + poll = Poll.objects.select_related( + "message", + "message__chat", + ).get(pk=poll_id, message__chat_id=chat_id) + except Poll.DoesNotExist: + return {"ok": False, "error": "Голосование не найдено"} + + try: + poll, selected_option_ids = cast_poll_vote( + poll, + user, + option_ids, + ) + except PollActionError as exc: + return {"ok": False, "error": exc.message} + + return { + "ok": True, + "poll_id": poll.id, + "message_id": poll.message_id, + "selected_option_ids": selected_option_ids, + "results": serialize_poll(poll), + } + @database_sync_to_async def _mark_read(self, chat, user): """ diff --git a/backend/communications/polls.py b/backend/communications/polls.py new file mode 100644 index 00000000..26c33134 --- /dev/null +++ b/backend/communications/polls.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from django.db import transaction +from django.db.models import Count +from django.utils import timezone + +from .models import Poll, PollOption, PollVote +from .utils import user_can_access_chat + + +@dataclass +class PollActionError(Exception): + message: str + status_code: int = 400 + + def __str__(self): + return self.message + + +def poll_is_effectively_closed(poll: Poll) -> bool: + return bool( + poll.is_closed + or (poll.closes_at and poll.closes_at <= timezone.now()) + ) + + +def cast_poll_vote(poll: Poll, user, option_ids) -> tuple[Poll, list[int]]: + """Replace a user's poll selection and refresh denormalized counters.""" + try: + normalized_ids = list(dict.fromkeys(int(option_id) for option_id in option_ids)) + except (TypeError, ValueError): + raise PollActionError("Некорректные варианты ответа") from None + + if not normalized_ids: + raise PollActionError("Выберите хотя бы один вариант") + + if not user_can_access_chat(poll.message.chat, user): + raise PollActionError("Нет доступа к этому голосованию", 403) + + closed_by_deadline = False + options = [] + with transaction.atomic(): + locked_poll = ( + Poll.objects.select_for_update() + .select_related("message", "message__chat") + .get(pk=poll.pk) + ) + + if poll_is_effectively_closed(locked_poll): + if not locked_poll.is_closed: + locked_poll.close() + closed_by_deadline = True + else: + if not locked_poll.is_multiple_choice and len(normalized_ids) > 1: + raise PollActionError("Only one option allowed") + + options = list( + locked_poll.options.filter(pk__in=normalized_ids).order_by( + "position", + "id", + ) + ) + if len(options) != len(normalized_ids): + raise PollActionError("Некорректные варианты ответа") + + PollVote.objects.filter(poll=locked_poll, voter=user).delete() + PollVote.objects.bulk_create( + [ + PollVote(poll=locked_poll, option=option, voter=user) + for option in options + ] + ) + + vote_counts = { + row["option_id"]: row["count"] + for row in ( + PollVote.objects.filter(poll=locked_poll) + .values("option_id") + .annotate(count=Count("id")) + ) + } + poll_options = list(locked_poll.options.all()) + for option in poll_options: + option.vote_count = vote_counts.get(option.id, 0) + PollOption.objects.bulk_update(poll_options, ["vote_count"]) + + locked_poll.total_voters = ( + PollVote.objects.filter(poll=locked_poll) + .values("voter_id") + .distinct() + .count() + ) + locked_poll.save(update_fields=["total_voters", "updated_at"]) + + if closed_by_deadline: + raise PollActionError("Poll is closed") + + return locked_poll, [option.id for option in options] diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index 07283efb..0ad014b7 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -86,6 +86,59 @@ def _get_user_avatar_url(user) -> str: return "" +def serialize_message_reader(user) -> dict: + """Краткие данные пользователя для read receipt сообщения.""" + return { + "id": user.id, + "name": user.get_full_name() or user.username, + "avatar": _get_user_avatar_url(user), + } + + +def serialize_poll(poll, user=None) -> dict: + from .polls import poll_is_effectively_closed + + user_option_ids = [] + if user and getattr(user, "is_authenticated", False): + user_option_ids = list( + poll.votes.filter(voter=user).values_list("option_id", flat=True) + ) + + total_voters = max(poll.total_voters, 0) + options = [] + for option in poll.options.all(): + percentage = ( + round(option.vote_count / total_voters * 100, 1) + if total_voters + else 0 + ) + options.append( + { + "id": option.id, + "text": option.text, + "position": option.position, + "vote_count": option.vote_count, + "percentage": percentage, + } + ) + + return { + "id": poll.id, + "message_id": poll.message_id, + "author_id": poll.author_id, + "question": poll.question, + "is_anonymous": poll.is_anonymous, + "is_multiple_choice": poll.is_multiple_choice, + "is_quiz": poll.is_quiz, + "is_closed": poll_is_effectively_closed(poll), + "closes_at": poll.closes_at.isoformat() if poll.closes_at else None, + "allows_custom_answers": poll.allows_custom_answers, + "total_voters": total_voters, + "user_voted_option_ids": user_option_ids, + "options": options, + } + + def _serialize_reply_preview(reply_msg) -> dict: is_deleted = bool(getattr(reply_msg, "is_deleted", False)) @@ -130,13 +183,7 @@ def _get_message_read_by(m) -> list[dict]: if not user: continue - readers.append( - { - "id": user.id, - "name": user.get_full_name() or user.username, - "avatar": _get_user_avatar_url(user), - } - ) + readers.append(serialize_message_reader(user)) readers.sort(key=lambda item: item["name"].lower()) return readers @@ -298,29 +345,7 @@ def serialize_message(m, user=None, include_linked_tasks=False) -> dict: # Голосование if hasattr(m, "poll"): - poll = m.poll - poll_data = { - "id": poll.id, - "question": poll.question, - "is_anonymous": poll.is_anonymous, - "is_multiple_choice": poll.is_multiple_choice, - "is_quiz": poll.is_quiz, - "is_closed": poll.is_closed, - "closes_at": poll.closes_at.isoformat() if poll.closes_at else None, - "total_voters": poll.total_voters, - "options": [], - } - for option in poll.options.all(): - poll_data["options"].append( - { - "id": option.id, - "text": option.text, - "position": option.position, - "vote_count": option.vote_count, - "percentage": 0, # Будет пересчитан на клиенте - } - ) - data["poll"] = poll_data + data["poll"] = serialize_poll(m.poll, user=user) # Ответ на сообщение if m.reply_to_id: diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index 818c724c..f99c432d 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -307,6 +307,10 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): """Обработчик для события poll.update (алиас для chat_poll_update)""" await self.chat_poll_update(event) + async def poll_update_sync(self, event): + """Персональная синхронизация выбора в голосовании между сессиями.""" + await self.chat_poll_update(event) + async def procurement_update(self, event): """ Обновление заявки на закупку. diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 1f3a1311..0fa9fee6 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -235,6 +235,66 @@ class TestChatViewSet: } ] + def test_marked_read_event_includes_reader_details( + self, + monkeypatch, + private_chat, + user2, + ): + """Read receipt содержит данные для обновления списка читателей без API-запроса.""" + from communications.api import viewsets as communications_viewsets + + sent_events = [] + + class FakeChannelLayer: + def group_send(self, group, event): + return None + + monkeypatch.setattr( + communications_viewsets, + "get_channel_layer", + lambda: FakeChannelLayer(), + ) + monkeypatch.setattr( + communications_viewsets, + "async_to_sync", + lambda _callback: lambda group, event: sent_events.append((group, event)), + ) + + communications_viewsets.ChatViewSet()._send_marked_read_event( + user2, + private_chat.id, + 321, + ) + + expected_reader = { + "id": user2.id, + "name": user2.get_full_name(), + "avatar": "", + } + assert sent_events == [ + ( + f"user_{user2.id}", + { + "type": "chat_marked_read_sync", + "chat_id": private_chat.id, + "last_read_message_id": 321, + "reader_user_id": user2.id, + "reader": expected_reader, + }, + ), + ( + f"chat_{private_chat.id}", + { + "type": "chat_marked_read", + "chat_id": private_chat.id, + "last_read_message_id": 321, + "reader_user_id": user2.id, + "reader": expected_reader, + }, + ), + ] + def test_mark_read(self, auth_client, private_chat, user1): """Пометка чата как прочитанного""" url = f"/api/v1/communications/chats/{private_chat.pk}/mark-read/" @@ -855,6 +915,117 @@ class TestPollViewSet: assert "options" in response.data assert response.data["total_voters"] == 1 + def test_create_poll_returns_message_payload( + self, + auth_client, + private_chat, + ): + response = auth_client.post( + "/api/v1/communications/polls/", + { + "chat_id": private_chat.id, + "question": "Где обедаем?", + "options_data": ["В офисе", "В кафе"], + "is_anonymous": False, + "is_multiple_choice": False, + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["poll"]["question"] == "Где обедаем?" + assert response.data["message"]["poll"]["options"] == [ + { + "id": response.data["poll"]["options"][0]["id"], + "text": "В офисе", + "position": 0, + "vote_count": 0, + "percentage": 0, + }, + { + "id": response.data["poll"]["options"][1]["id"], + "text": "В кафе", + "position": 1, + "vote_count": 0, + "percentage": 0, + }, + ] + + def test_create_poll_rejects_duplicate_options( + self, + auth_client, + private_chat, + ): + response = auth_client.post( + "/api/v1/communications/polls/", + { + "chat_id": private_chat.id, + "question": "Повтор?", + "options_data": ["Да", " да "], + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "options_data" in response.data + + def test_poll_is_hidden_from_user_without_chat_access( + self, + user3, + poll_message, + ): + _message, poll, option, _option2 = poll_message + client = APIClient() + client.force_authenticate(user=user3) + + response = client.post( + f"/api/v1/communications/polls/{poll.id}/vote/", + {"option_ids": [option.id]}, + format="json", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_poll_deadline_closes_voting( + self, + auth_client, + poll_message, + ): + from datetime import timedelta + + _message, poll, option, _option2 = poll_message + poll.closes_at = timezone.now() - timedelta(seconds=1) + poll.save(update_fields=["closes_at"]) + + response = auth_client.post( + f"/api/v1/communications/polls/{poll.id}/vote/", + {"option_ids": [option.id]}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + poll.refresh_from_db() + assert poll.is_closed is True + + def test_poll_does_not_expose_generic_update_or_delete( + self, + auth_client, + poll_message, + ): + _message, poll, _option, _option2 = poll_message + + patch_response = auth_client.patch( + f"/api/v1/communications/polls/{poll.id}/", + {"question": "Измененный вопрос"}, + format="json", + ) + delete_response = auth_client.delete( + f"/api/v1/communications/polls/{poll.id}/" + ) + + assert patch_response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert delete_response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + # ==================== Integration Tests ==================== diff --git a/backend/tests/realtime/test_websocket_events.py b/backend/tests/realtime/test_websocket_events.py index c9a70013..3f0f6ce0 100644 --- a/backend/tests/realtime/test_websocket_events.py +++ b/backend/tests/realtime/test_websocket_events.py @@ -479,6 +479,83 @@ class TestPollWebSocketEvents: with pytest.raises(asyncio.TimeoutError): await comm.receive_json_from(timeout=2) + @pytest.mark.asyncio + async def test_vote_poll_action_updates_all_active_participants( + self, + ws_communicator, + user, + user2, + test_chat_with_users, + ): + def create_poll(): + from communications.models import Message, Poll, PollOption + + message = Message.objects.create( + chat=test_chat_with_users, + author=user2, + content="Выберите вариант", + ) + poll = Poll.objects.create( + message=message, + author=user2, + question="Выберите вариант", + ) + option = PollOption.objects.create( + poll=poll, + text="Первый", + position=0, + ) + PollOption.objects.create( + poll=poll, + text="Второй", + position=1, + ) + return poll.id, message.id, option.id + + poll_id, message_id, option_id = await database_sync_to_async(create_poll)() + comm_voter = await ws_communicator(user=user) + comm_other = await ws_communicator(user=user2) + assert (await comm_voter.connect())[0] + assert (await comm_other.connect())[0] + + for communicator in (comm_voter, comm_other): + await communicator.send_json_to( + { + "action": "open_chat", + "chat_id": test_chat_with_users.id, + "load_history": False, + } + ) + opened = await communicator.receive_json_from(timeout=5) + assert opened["type"] == "chat_opened" + + await comm_voter.send_json_to( + { + "action": "vote_poll", + "poll_id": poll_id, + "option_ids": [option_id], + } + ) + + voter_public_update = await comm_voter.receive_json_from(timeout=5) + voter_update = await comm_voter.receive_json_from(timeout=5) + other_update = await comm_other.receive_json_from(timeout=5) + for update in (voter_public_update, other_update): + assert update["type"] == "poll_update" + assert update["message_id"] == message_id + assert update["voter_user_id"] is None + assert update["selected_option_ids"] == [] + assert update["results"]["total_voters"] == 1 + assert update["results"]["options"][0]["vote_count"] == 1 + + assert voter_update["type"] == "poll_update" + assert voter_update["message_id"] == message_id + assert voter_update["voter_user_id"] == user.id + assert voter_update["selected_option_ids"] == [option_id] + + await comm_voter.disconnect() + await comm_other.disconnect() + class TestChatMarkedReadEvent: """Тесты WebSocket события отметки прочтения.""" @@ -517,6 +594,11 @@ class TestChatMarkedReadEvent: # Отправляем sync-событие в пользовательский канал (как делает API) channel_layer = get_channel_layer() mock_message_id = 988 + reader = { + "id": user.id, + "name": user.get_full_name() or user.username, + "avatar": "", + } await channel_layer.group_send( f'user_{user.id}', @@ -525,6 +607,7 @@ class TestChatMarkedReadEvent: 'chat_id': test_chat.id, 'last_read_message_id': mock_message_id, 'reader_user_id': user.id, + 'reader': reader, } ) @@ -534,12 +617,14 @@ class TestChatMarkedReadEvent: assert response1["chat_id"] == test_chat.id assert response1["last_read_message_id"] == mock_message_id assert response1["reader_user_id"] == user.id + assert response1["reader"] == reader response2 = await comm2.receive_json_from(timeout=5) assert response2["type"] == "marked_read" assert response2["chat_id"] == test_chat.id assert response2["last_read_message_id"] == mock_message_id assert response2["reader_user_id"] == user.id + assert response2["reader"] == reader await comm1.disconnect() await comm2.disconnect() @@ -585,6 +670,11 @@ class TestChatMarkedReadEvent: # Отправляем read receipt в канал чата (как делает API для других участников) channel_layer = get_channel_layer() mock_message_id = 987 + reader = { + "id": user.id, + "name": user.get_full_name() or user.username, + "avatar": "", + } await channel_layer.group_send( f'chat_{test_chat_with_users.id}', @@ -593,6 +683,7 @@ class TestChatMarkedReadEvent: 'chat_id': test_chat_with_users.id, 'last_read_message_id': mock_message_id, 'reader_user_id': user.id, + 'reader': reader, } ) @@ -602,6 +693,7 @@ class TestChatMarkedReadEvent: assert response_other["chat_id"] == test_chat_with_users.id assert response_other["last_read_message_id"] == mock_message_id assert response_other["reader_user_id"] == user.id + assert response_other["reader"] == reader # Сам читатель не должен получать receipt из chat-группы with pytest.raises(asyncio.TimeoutError): diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index e6f0e86f..cd04479f 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -15,6 +15,7 @@ import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; import ChatSearchPanel from "@/components/messages/ChatSearchPanel"; +import CreatePollModal from "@/components/messages/CreatePollModal"; import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; @@ -23,6 +24,7 @@ import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks import type { ChatMessageSearchResponse, ChatMessageSearchResult, + CreatePollPayload, Message, TaskBoard, TaskCard, @@ -39,6 +41,8 @@ import { markChatReadLocally } from "@/hooks/useMessagesChatList"; import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll"; import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; +import { applyMessageReadReceipt } from "@/lib/messages/messageReadState.js"; +import { applyPollUpdateToMessages } from "@/lib/messages/pollState.js"; import { displayUserName } from "@/lib/shared"; import wsManager from "@/lib/websocketManager"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; @@ -238,6 +242,7 @@ function MessageDialogPageContent() { const [isReadersModalOpen, setIsReadersModalOpen] = useState(false); const [reactionPickerForMessageId, setReactionPickerForMessageId] = useState(null); const [showComposerEmojiPicker, setShowComposerEmojiPicker] = useState(false); + const [isCreatePollOpen, setIsCreatePollOpen] = useState(false); const [recentReactions, setRecentReactions] = useState(ALL_REACTIONS.slice(0, MAX_RECENT_REACTIONS)); const [attachedFiles, setAttachedFiles] = useState([]); const [brokenMedia, setBrokenMedia] = useState>({}); @@ -482,18 +487,24 @@ function MessageDialogPageContent() { if (data.reactions_summary && typeof data.message_id === "number") { updateMessageReactionsSummary(data.message_id, data.reactions_summary); } + } else if (data.type === "poll_update" && data.results && typeof data.message_id === "number") { + const pollMessageId = data.message_id; + const pollResults = data.results; + cm.setMessages(prev => applyPollUpdateToMessages( + prev, + pollMessageId, + pollResults, + user?.id, + data.voter_user_id, + data.selected_option_ids || [], + )); } else if (data.type === "marked_read" && data.chat_id === chatId && typeof data.last_read_message_id === "number") { const lastReadId = data.last_read_message_id; const readerId = typeof data.reader_user_id === "number" ? data.reader_user_id : null; + const reader = data.reader; - if (readerId !== null) { - cm.setMessages(prev => - prev.map(m => { - const aId = m.author_id ?? m.author?.id ?? m.sender?.id; - if (m.id > lastReadId || aId === readerId || m.is_read) return m; - return { ...m, is_read: true }; - }), - ); + if (reader && readerId !== null && reader.id === readerId) { + cm.setMessages(prev => applyMessageReadReceipt(prev, lastReadId, reader)); } if (readerId === null || readerId === user?.id) { @@ -733,6 +744,46 @@ function MessageDialogPageContent() { const removeAttachedFile = (index: number) => setAttachedFiles(prev => prev.filter((_, i) => i !== index)); const handlePickFiles = () => { if (!editingMessageId) fileInputRef.current?.click(); }; + const handleCreatePoll = async (payload: CreatePollPayload) => { + const wasNearBottom = scroll.isNearBottom(); + const response = await apiClient.createPoll(payload); + cm.setMessages(prev => uniqueMessagesById([...prev, response.message])); + setReplyTo(null); + if (wasNearBottom) { + requestAnimationFrame(() => scroll.scrollToBottom(false)); + } + }; + + const handleVotePoll = async (pollId: number, optionIds: number[]) => { + const response = await apiClient.votePoll(pollId, optionIds); + cm.setMessages(prev => { + const message = prev.find(item => item.poll?.id === pollId); + if (!message) return prev; + return applyPollUpdateToMessages( + prev, + message.id, + response.results, + user?.id, + user?.id, + response.results.user_voted_option_ids, + ); + }); + }; + + const handleClosePoll = async (pollId: number) => { + const poll = await apiClient.closePoll(pollId); + cm.setMessages(prev => { + const message = prev.find(item => item.poll?.id === pollId); + if (!message) return prev; + return applyPollUpdateToMessages( + prev, + message.id, + poll, + user?.id, + ); + }); + }; + const canRetryMessage = useCallback((message: Message): boolean => { if (message.send_state !== "failed" || !message.local_id) return false; const hasAttachments = Boolean(message.attachments?.length); @@ -1479,6 +1530,8 @@ function MessageDialogPageContent() { onShowAllReaders={() => setIsReadersModalOpen(true)} onLinkToTask={handleOpenTaskLinkModal} onRetry={handleRetryMessage} + onVotePoll={handleVotePoll} + onClosePoll={handleClosePoll} isRetrying={Boolean(message.local_id && retryingLocalIds.has(message.local_id))} retryDisabled={!canRetryMessage(message)} onReply={handleReplyToMessage} @@ -1539,6 +1592,10 @@ function MessageDialogPageContent() { fileInputRef={fileInputRef} messageInputRef={messageInputRef} onPickFiles={handlePickFiles} + onCreatePoll={() => { + setShowComposerEmojiPicker(false); + setIsCreatePollOpen(true); + }} onAddFiles={addAttachedFiles} onFilesChange={handleFilesChange} onRemoveFile={removeAttachedFile} @@ -1578,6 +1635,14 @@ function MessageDialogPageContent() { setIsReadersModalOpen(false)} readers={selectedActionReaders} /> ) : null} + setIsCreatePollOpen(false)} + onCreate={handleCreatePoll} + /> + void; onLinkToTask?: (message: Message) => void; onRetry?: (message: Message) => void; + onVotePoll: (pollId: number, optionIds: number[]) => Promise; + onClosePoll: (pollId: number) => Promise; isRetrying?: boolean; retryDisabled?: boolean; onReply: (message: Message) => void; @@ -157,6 +160,8 @@ export default function ChatMessageItem({ onShowAllReaders, onLinkToTask, onRetry, + onVotePoll, + onClosePoll, isRetrying = false, retryDisabled = false, onReply, @@ -237,7 +242,9 @@ export default function ChatMessageItem({ type="button" data-actions-trigger="true" onClick={() => onToggleActions(message.id)} - className={`inline-flex h-6 w-6 items-center justify-center rounded-full border border-transparent bg-transparent text-[var(--muted-foreground)] transition hover:text-sky-600 ${ + className={`inline-flex h-6 w-6 items-center justify-center rounded-full border border-transparent bg-transparent transition ${ + isMine ? "text-white/80 hover:text-white" : "text-[var(--muted-foreground)] hover:text-sky-600" + } ${ isActionsOpen ? "rotate-90" : "" }`} title="Действия" @@ -301,12 +308,22 @@ export default function ChatMessageItem({ {message.is_deleted ? (

Сообщение удалено

- ) : message.content ? ( + ) : message.content && !message.poll ? (

{message.content}

) : null} + {!message.is_deleted && message.poll ? ( + + ) : null} + {message.attachments && message.attachments.length > 0 ? (
{message.attachments.map((attachment) => { diff --git a/frontend/src/components/messages/CreatePollModal.tsx b/frontend/src/components/messages/CreatePollModal.tsx new file mode 100644 index 00000000..c10b9b17 --- /dev/null +++ b/frontend/src/components/messages/CreatePollModal.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState } from "react"; +import { Clock3, Plus, Trash2 } from "lucide-react"; + +import { Modal } from "@/components/ui"; +import type { CreatePollPayload } from "@/types/api"; + +type CreatePollModalProps = { + isOpen: boolean; + chatId: number; + replyToId?: number; + onClose: () => void; + onCreate: (payload: CreatePollPayload) => Promise; +}; + +const closeOptions = [ + { value: "", label: "Не ограничивать" }, + { value: "60", label: "Через 1 час" }, + { value: "1440", label: "Через 1 день" }, + { value: "10080", label: "Через 7 дней" }, +]; + +function PollToggle({ + checked, + onChange, + label, + description, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + label: string; + description: string; +}) { + return ( + + ); +} + +export default function CreatePollModal({ + isOpen, + chatId, + replyToId, + onClose, + onCreate, +}: CreatePollModalProps) { + const [question, setQuestion] = useState(""); + const [options, setOptions] = useState(["", ""]); + const [multiple, setMultiple] = useState(false); + const [anonymous, setAnonymous] = useState(false); + const [closesIn, setClosesIn] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setQuestion(""); + setOptions(["", ""]); + setMultiple(false); + setAnonymous(false); + setClosesIn(""); + setError(null); + }; + + const close = () => { + if (submitting) return; + reset(); + onClose(); + }; + + const canSubmit = question.trim().length > 0 + && options.length >= 2 + && options.every((option) => option.trim().length > 0); + + const submit = async () => { + if (!canSubmit || submitting) return; + setSubmitting(true); + setError(null); + try { + await onCreate({ + chat_id: chatId, + question: question.trim(), + options_data: options.map((option) => option.trim()), + is_anonymous: anonymous, + is_multiple_choice: multiple, + closes_in_minutes: closesIn ? Number(closesIn) : null, + reply_to_id: replyToId, + }); + reset(); + onClose(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Не удалось создать голосование"); + } finally { + setSubmitting(false); + } + }; + + const footer = ( +
+ + +
+ ); + + return ( + +
+ {error ?

{error}

: null} + +
+