""" Communications API ViewSets DRF ViewSets для управления чатами, сообщениями и голосованиями. Полностью заменяет устаревшие function-based views. Endpoints: - /api/v1/communications/chats/ - ChatViewSet - /api/v1/communications/messages/ - MessageViewSet - /api/v1/communications/polls/ - PollViewSet История: - Миграция с FBV на ViewSets завершена: 14 января 2026 - Удаление legacy кода: 15 января 2026 """ import logging from datetime import datetime from datetime import timezone as dt_tz from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.contrib.auth import get_user_model from django.db import transaction from django.db.models import Prefetch, Q from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from .permissions import ChatPermission, MessagePermission from .serializers import ( BulkDeleteSerializer, ChatDetailSerializer, ChatListSerializer, ChatNotificationPreferencesUpdateSerializer, ChatUserSettingsSerializer, ForwardMessageSerializer, MessageCreateSerializer, MessageDetailSerializer, MessageEditSerializer, MessageListSerializer, PollSerializer, ReactionSerializer, ) from ..models import ( Chat, ChatMembership, ChatReadState, ChatUserSettings, Message, MessageAttachment, MessageReaction, Poll, ) from ..mentions import ( build_message_mentions, filter_mentionable_users, get_mentionable_users, mentioned_user_ids as get_mentioned_user_ids, mention_display_name, parse_mentioned_user_ids, update_unread_mention_counters, ) from ..polls import PollActionError, cast_poll_vote from ..services import soft_delete_message, update_chat_notification_preferences from ..utils import _coerce_ts, user_can_access_chat from communications.serialization import ( serialize_message, serialize_message_reader, serialize_poll, ) logger = logging.getLogger(__name__) User = get_user_model() def _broadcast_message_deleted(chat_id, message_id, author_id): channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( f"chat_{chat_id}", { "type": "chat.message_deleted", "chat_id": chat_id, "message_id": message_id, "author_id": author_id, }, ) def _parse_bool_query_param(value, default=True): if value is None: return default normalized = str(value).strip().lower() if normalized in {"1", "true", "yes", "on"}: return True if normalized in {"0", "false", "no", "off"}: return False return default def _build_message_search_snippet(message, query, max_length=140): query_normalized = (query or "").strip().lower() content = (message.content or "").strip() if content: if not query_normalized: return ( f"{content[:max_length].rstrip()}…" if len(content) > max_length else content ) content_normalized = content.lower() match_index = content_normalized.find(query_normalized) if match_index < 0: return ( f"{content[:max_length].rstrip()}…" if len(content) > max_length else content ) context_before = max_length // 3 context_after = max_length - context_before - len(query_normalized) start = max(0, match_index - context_before) end = min(len(content), match_index + len(query_normalized) + context_after) snippet = content[start:end].strip() if start > 0: snippet = f"…{snippet}" if end < len(content): snippet = f"{snippet}…" return snippet attachment_names = [ a.file_name for a in getattr(message, "attachments").all() if a.file_name ] if attachment_names: joined_names = ", ".join(attachment_names) return f"Файлы: {joined_names}" return "Сообщение без текста" def _filter_messages_before_anchor(queryset, chat, before_id): try: anchor_id = int(before_id) except (TypeError, ValueError): return queryset anchor = chat.messages.only("id", "created_at").filter(pk=anchor_id).first() if not anchor: return queryset.filter(id__lt=anchor_id) return queryset.filter( Q(created_at__lt=anchor.created_at) | Q(created_at=anchor.created_at, id__lt=anchor.id) ) def _filter_messages_after_anchor(queryset, chat, after_id): try: anchor_id = int(after_id) except (TypeError, ValueError): return queryset anchor = chat.messages.only("id", "created_at").filter(pk=anchor_id).first() if not anchor: return queryset.filter(id__gt=anchor_id) return queryset.filter( Q(created_at__gt=anchor.created_at) | Q(created_at=anchor.created_at, id__gt=anchor.id) ) class ChatViewSet(viewsets.ModelViewSet): """ ViewSet для управления чатами list: GET /api/v1/communications/chats/ - список чатов пользователя retrieve: GET /api/v1/communications/chats/{id}/ - детали чата create: POST /api/v1/communications/chats/ - создать чат Actions: - pin: POST /api/v1/communications/chats/{id}/pin/ - закрепить/открепить - notifications: POST /api/v1/communications/chats/{id}/notifications/ - вкл/выкл уведомления - messages: GET /api/v1/communications/chats/{id}/messages/ - список сообщений (автоотметка) - messages_around: GET /api/v1/communications/chats/{id}/messages_around/ - сообщения вокруг (автоотметка) - mark_read: POST /api/v1/communications/chats/{id}/mark_read/ - [DEPRECATED] используйте GET /messages/ Автоматическая отметка прочитанных (Telegram-style): При любой загрузке сообщений (messages, messages_around) последнее загруженное сообщение автоматически отмечается как прочитанное через last_read_message_id. Ручной вызов /mark-read/ больше не требуется. """ queryset = Chat.objects.none() permission_classes = [ChatPermission] def get_serializer_class(self): if self.action == "list": return ChatListSerializer return ChatDetailSerializer def get_queryset(self): """ Чаты доступные пользователю с аннотацией непрочитанных. ОПТИМИЗИРОВАНО: Используем денормализованное поле unread_count из ChatReadState вместо подзапросов COUNT(*). Это убирает N+1 проблему и ускоряет в ~100x. ФИЛЬТРАЦИЯ: Чаты типа 'comments' по умолчанию исключены из списка, кроме явно помеченных object-chats (`flags.show_in_messages=true`). По прямой ссылке retrieve доступны все comments-чаты. """ user = self.request.user queryset = ( Chat.objects.filter( Q(memberships__user=user, memberships__is_active=True) | Q(participants=user) | Q(include_all_users=True) | Q(created_by=user) # Создатель всегда видит свои чаты | Q(type="comments") ) .select_related("created_by", "context_content_type") .prefetch_related( # Prefetch ChatMembership с информацией о пользователе Prefetch( "memberships", queryset=ChatMembership.objects.select_related("user").filter( is_active=True ), ), # Prefetch ChatUserSettings для текущего пользователя Prefetch( "user_settings", queryset=ChatUserSettings.objects.filter(user=user), to_attr="my_settings", ), # Prefetch ChatReadState для текущего пользователя (с unread_count!) Prefetch( "read_states", queryset=ChatReadState.objects.filter(user=user), to_attr="my_read_state", ), # Prefetch последнее сообщение чата (1 запрос вместо N) Prefetch( "messages", queryset=Message.objects.filter( is_deleted=False, ) .select_related("author", "poll") .prefetch_related("attachments") .order_by("-created_at")[:1], to_attr="_prefetched_last_message", ), ) .distinct() ) # В список попадают только явно выставленные comments-чаты. # Остальные object-comment threads доступны по прямому retrieve. if self.action == "list": queryset = queryset.exclude( Q(type="comments") & ~Q(flags__show_in_messages=True) ) return queryset.order_by("-created_at") def create(self, request, *args, **kwargs): """Создание чата""" raw_participant_ids = ( request.data.getlist("participants") if hasattr(request.data, "getlist") else request.data.get("participants", []) ) if raw_participant_ids in (None, ""): raw_participant_ids = [] elif not isinstance(raw_participant_ids, (list, tuple)): raw_participant_ids = [raw_participant_ids] try: participant_ids = { int(user_id) for user_id in raw_participant_ids if user_id not in (None, "") } except (TypeError, ValueError): return Response( {"participants": ["Идентификаторы участников должны быть числами."]}, status=status.HTTP_400_BAD_REQUEST, ) valid_participant_ids = set( User.objects.filter(id__in=participant_ids, is_active=True).values_list( "id", flat=True ) ) if participant_ids - valid_participant_ids: return Response( {"participants": ["Добавлять можно только активных сотрудников."]}, status=status.HTTP_400_BAD_REQUEST, ) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) # Устанавливаем создателя chat = serializer.save(created_by=request.user) if chat.type == "global": chat.include_all_users = True chat.save() else: role = ( "admin" if chat.type in ["group", "channel", "announcement"] else "member" ) chat.participants.add(request.user) ChatMembership.objects.create( chat=chat, user=request.user, role=role, invited_by=request.user, is_active=True, ) # Добавляем остальных участников из запроса if participant_ids: for user_id in participant_ids: if user_id == request.user.id: continue try: participant = User.objects.get(id=user_id, is_active=True) chat.participants.add(participant) ChatMembership.objects.get_or_create( chat=chat, user=participant, defaults={ "role": "member", "invited_by": request.user, "is_active": True, }, ) except User.DoesNotExist: logger.warning( "Chat participant was not found during creation", extra={ "event": "chat.participant.add_skipped", "chat_id": chat.id, "participant_id": user_id, "reason": "user_missing", }, ) headers = self.get_success_headers(serializer.data) return Response( ChatDetailSerializer(chat, context={"request": request}).data, status=status.HTTP_201_CREATED, headers=headers, ) @action(detail=True, methods=["post"]) def pin(self, request, pk=None): """Закрепить/открепить чат""" chat = self.get_object() settings, created = ChatUserSettings.objects.get_or_create( chat=chat, user=request.user ) settings.is_pinned = not settings.is_pinned settings.save() return Response({"ok": True, "is_pinned": settings.is_pinned}) @action(detail=True, methods=["post"]) def notifications(self, request, pk=None): """Update per-chat notification mode and presentation preferences.""" chat = self.get_object() settings, _ = ChatUserSettings.objects.get_or_create( chat=chat, user=request.user ) payload = request.data.copy() if "mode" in payload and "notification_mode" not in payload: payload["notification_mode"] = payload["mode"] serializer = ChatNotificationPreferencesUpdateSerializer(data=payload) serializer.is_valid(raise_exception=True) update_chat_notification_preferences( settings, preferences=serializer.validated_data, ) return Response( {"ok": True, **ChatUserSettingsSerializer(settings).data} ) @action(detail=True, methods=["get"], url_path="mention-candidates") def mention_candidates(self, request, pk=None): """Active chat participants available for an @mention.""" chat = self.get_object() query = str(request.query_params.get("q", "")).strip()[:100] users = get_mentionable_users(chat, author=request.user).order_by( "last_name", "first_name", "patronymic", "id" ) if query: users = filter_mentionable_users(users, query) candidates = [] for user in users: candidates.append( { "id": user.id, "name": mention_display_name(user), "email": user.email, "avatar": user.avatar.url if getattr(user, "avatar", None) else None, } ) return Response({"results": candidates}) @action(detail=True, methods=["post"]) def leave(self, request, pk=None): """Покинуть чат (выход пользователя из чата)""" import rules chat = self.get_object() # Проверка прав через django-rules if not rules.test_rule("communications.leave_chat", request.user, chat): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) # Нельзя покинуть чат, если ты его владелец if chat.created_by == request.user: return Response( {"error": "Chat owner cannot leave the chat"}, status=status.HTTP_400_BAD_REQUEST, ) # MIGRATION: Убрали participants.remove, только деактивируем membership membership = ChatMembership.objects.filter(chat=chat, user=request.user).first() if membership: membership.is_active = False membership.left_at = timezone.now() membership.save(update_fields=["is_active", "left_at"]) chat.participants.remove(request.user) else: return Response( {"error": "You are not a member of this chat"}, status=status.HTTP_400_BAD_REQUEST, ) return Response({"ok": True, "message": "Successfully left the chat"}) @action(detail=True, methods=["post"], url_path="add-member") def add_member(self, request, pk=None): """Добавить участника в чат""" import rules chat = self.get_object() user_id = request.data.get("user_id") if not user_id: return Response( {"error": "user_id is required"}, status=status.HTTP_400_BAD_REQUEST ) # Проверка прав if not rules.test_rule("communications.add_members", request.user, chat): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) user_to_add = User.objects.filter(pk=user_id).first() if user_to_add is None: return Response( {"error": "User not found"}, status=status.HTTP_404_NOT_FOUND ) if not user_to_add.is_active: return Response( {"error": "Active user not found"}, status=status.HTTP_400_BAD_REQUEST ) # MIGRATION: Проверяем через memberships вместо participants if ChatMembership.objects.filter( chat=chat, user=user_to_add, is_active=True ).exists(): return Response( {"error": "User is already a member"}, status=status.HTTP_400_BAD_REQUEST, ) # Создаем или восстанавливаем membership для чатов с управлением участниками if chat.type in ["group", "channel", "announcement"]: membership, created = ChatMembership.objects.get_or_create( chat=chat, user=user_to_add, defaults={ "role": "member", "invited_by": request.user, "is_active": True, }, ) # Если membership уже существовал (например, пользователь раньше покинул чат), # восстанавливаем его активность if not created and not membership.is_active: membership.is_active = True membership.left_at = None membership.invited_by = request.user # Обновляем кто пригласил повторно membership.save() chat.participants.add(user_to_add) return Response({"ok": True, "message": "User added successfully"}) @action(detail=True, methods=["post"], url_path="remove-member") def remove_member(self, request, pk=None): """Удалить участника из чата""" import rules chat = self.get_object() user_id = request.data.get("user_id") if not user_id: return Response( {"error": "user_id is required"}, status=status.HTTP_400_BAD_REQUEST ) # Проверка прав if not rules.test_rule("communications.remove_members", request.user, chat): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) try: user_to_remove = User.objects.get(pk=user_id) except User.DoesNotExist: return Response( {"error": "User not found"}, status=status.HTTP_404_NOT_FOUND ) # Нельзя удалить владельца if chat.created_by == user_to_remove: return Response( {"error": "Cannot remove chat owner"}, status=status.HTTP_400_BAD_REQUEST, ) # MIGRATION: Убрали participants.remove, только деактивируем membership membership = ChatMembership.objects.filter( chat=chat, user=user_to_remove ).first() if not membership: return Response( {"error": "User is not a member"}, status=status.HTTP_400_BAD_REQUEST ) membership.is_active = False membership.left_at = timezone.now() membership.save(update_fields=["is_active", "left_at"]) chat.participants.remove(user_to_remove) return Response({"ok": True, "message": "User removed successfully"}) @action(detail=True, methods=["post"], url_path="change-role") def change_role(self, request, pk=None): """Изменить роль участника чата""" import rules chat = self.get_object() user_id = request.data.get("user_id") new_role = request.data.get("role") if not user_id or not new_role: return Response( {"error": "user_id and role are required"}, status=status.HTTP_400_BAD_REQUEST, ) # Проверка валидности роли valid_roles = ["admin", "moderator", "member", "guest"] if new_role not in valid_roles: return Response( {"error": f"Invalid role. Must be one of: {', '.join(valid_roles)}"}, status=status.HTTP_400_BAD_REQUEST, ) # Проверка прав (только владелец может менять роли) can_change = rules.test_rule( "communications.change_member_role", request.user, chat ) # Добавляем логирование для отладки logger.debug( "Chat role change permission evaluated", extra={ "event": "chat.role_change.permission_evaluated", "chat_id": chat.id, "actor_id": request.user.id, "owner_id": chat.created_by_id, "allowed": can_change, }, ) if not can_change: return Response( {"error": "Permission denied. Only chat owner can change roles."}, status=status.HTTP_403_FORBIDDEN, ) user_to_change = User.objects.filter(pk=user_id).first() if user_to_change is None: return Response( {"error": "User not found"}, status=status.HTTP_404_NOT_FOUND ) if not user_to_change.is_active: return Response( {"error": "Active user not found"}, status=status.HTTP_400_BAD_REQUEST ) # Нельзя изменить роль владельца if chat.created_by == user_to_change: return Response( {"error": "Cannot change role of chat owner"}, status=status.HTTP_400_BAD_REQUEST, ) # Получаем или создаем membership membership, created = ChatMembership.objects.get_or_create( chat=chat, user=user_to_change, defaults={ "role": new_role, "invited_by": request.user, "is_active": True, # Явно устанавливаем is_active при создании }, ) logger.debug( "Chat membership prepared for role change", extra={ "event": "chat.role_change.membership_prepared", "chat_id": chat.id, "participant_id": user_to_change.id, "membership_created": created, "previous_role": membership.role, "new_role": new_role, "membership_active": membership.is_active, }, ) if not created: # Обновляем существующий membership old_role = membership.role old_is_active = membership.is_active membership.role = new_role membership.is_active = True # Убеждаемся что is_active = True membership.left_at = None # Сбрасываем left_at если был membership.set_permissions_for_role() membership.save() # Перезагружаем из БД чтобы убедиться что сохранилось membership.refresh_from_db() logger.info( "Chat member role changed", extra={ "event": "chat.role_changed", "chat_id": chat.id, "participant_id": user_to_change.id, "previous_role": old_role, "new_role": membership.role, "previously_active": old_is_active, "membership_active": membership.is_active, }, ) return Response( { "ok": True, "message": f"User role changed to {new_role}", "membership": { "user_id": membership.user_id, "role": membership.role, "can_send_messages": membership.can_send_messages, "can_add_members": membership.can_add_members, "can_remove_members": membership.can_remove_members, "can_pin_messages": membership.can_pin_messages, "can_manage_members": membership.can_manage_members, }, } ) def _auto_mark_read(self, chat, user, messages): """ Автоматически отмечает последнее загруженное сообщение как прочитанное. Pragmatic подход: "загрузил = прочитал" с ограничением количества новых сообщений. Защита от откатов: обновляет только если новое сообщение НОВЕЕ текущего. Денормализация: обнуляет unread_count при прочтении. """ if not messages: return last_message = messages[-1] read_state, created = ChatReadState.objects.get_or_create( chat=chat, user=user, defaults={ "last_read_message": last_message, "unread_count": 0, # Прочитали → обнуляем "unread_mention_count": 0, }, ) if created: logger.info( "Chat read marker created", extra={ "event": "chat.read_marker.created", "user_id": user.id, "chat_id": chat.id, "message_id": last_message.id, }, ) self._send_marked_read_event(user, chat.id, last_message.id) return # Защита от откатов: только если НОВЕЕ if ( read_state.last_read_message_id and last_message.id <= read_state.last_read_message_id ): logger.debug( "Chat read marker update skipped", extra={ "event": "chat.read_marker.update_skipped", "user_id": user.id, "chat_id": chat.id, "message_id": last_message.id, "current_message_id": read_state.last_read_message_id, "reason": "marker_not_newer", }, ) return read_state.last_read_message = last_message read_state.unread_count = 0 # Прочитали → обнуляем read_state.unread_mention_count = 0 read_state.save( update_fields=[ "last_read_message", "unread_count", "unread_mention_count", "updated_at", ] ) logger.info( "Chat read marker updated", extra={ "event": "chat.read_marker.updated", "user_id": user.id, "chat_id": chat.id, "message_id": last_message.id, }, ) self._send_marked_read_event(user, chat.id, last_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": reader, } async_to_sync(channel_layer.group_send)( f"user_{user.id}", { "type": "chat_marked_read_sync", **event_payload, }, ) async_to_sync(channel_layer.group_send)( f"chat_{chat_id}", { "type": "chat_marked_read", **event_payload, }, ) @action(detail=True, methods=["get"]) def messages(self, request, pk=None): """Загрузка сообщений чата (пагинация по времени). Query params: - mark_read=false: не обновлять last_read_message при after_id/after_ts. Нужен для тихой синхронизации клиента после разрыва WebSocket. """ chat = self.get_object() # Проверка доступа if not user_can_access_chat(chat, request.user): return Response( {"error": "Access denied"}, status=status.HTTP_403_FORBIDDEN ) # Параметры пагинации before_ts = request.query_params.get("before") before_id = request.query_params.get("before_id") after_ts = request.query_params.get("after") after_id = request.query_params.get("after_id") try: limit = min(max(int(request.query_params.get("limit", 50)), 0), 100) except (ValueError, TypeError): limit = 50 mark_read = _parse_bool_query_param( request.query_params.get("mark_read"), default=True, ) queryset = ( chat.messages.filter(is_deleted=False) .select_related("author", "reply_to", "reply_to__author", "poll") .prefetch_related( "attachments", "reactions", "reactions__user", "poll__options", Prefetch( "chat__read_states", queryset=ChatReadState.objects.select_related("user"), ), ) ) # Определяем порядок сортировки в зависимости от типа запроса if after_id or after_ts: # Для загрузки новых сообщений - сортируем по возрастанию (от старых к # новым) queryset = queryset.order_by("created_at", "id") else: # Для загрузки старых или начальной загрузки - по убыванию (от новых к # старым) queryset = queryset.order_by("-created_at", "-id") # Фильтрация по ID (приоритет) или timestamp if before_id: queryset = _filter_messages_before_anchor(queryset, chat, before_id) elif before_ts: before_dt = _coerce_ts(before_ts) if before_dt: queryset = queryset.filter(created_at__lt=before_dt) if after_id: queryset = _filter_messages_after_anchor(queryset, chat, after_id) elif after_ts: after_dt = _coerce_ts(after_ts) if after_dt: queryset = queryset.filter(created_at__gt=after_dt) # Берем limit+1 чтобы определить has_more messages = list(queryset[: limit + 1]) has_more = len(messages) > limit if has_more: messages = messages[:limit] # Убираем лишнее # Если загружали с after - уже в прямом порядке, иначе переворачиваем if not (after_id or after_ts): messages.reverse() # Автоотметка при загрузке НОВЫХ сообщений (after_id/after_ts) # При scroll вверх (before_id) - не отмечаем if mark_read and (after_id or after_ts) and messages: self._auto_mark_read(chat, request.user, messages) return Response( { "messages": [ serialize_message( m, user=request.user, include_linked_tasks=True, ) for m in messages ], "has_more": has_more, } ) @action(detail=True, methods=["get"], url_path="messages-around") def messages_around(self, request, pk=None): """ Загрузка сообщений вокруг указанного (last_read_message_id). Асимметричная загрузка для браузерных приложений: - 24 сообщения ДО (контекст прочитанных) - 10 сообщений ПОСЛЕ (новые непрочитанные) Автоматическая отметка последнего загруженного как прочитанного. Погрешность: макс 1-2 невидимых сообщения (приемлемо для веб). """ chat = self.get_object() if not user_can_access_chat(chat, request.user): return Response( {"error": "Access denied"}, status=status.HTTP_403_FORBIDDEN ) around_id = request.query_params.get("around_id") # Асимметричные лимиты (24 + 10 = 34 сообщения ) try: before_limit = int(request.query_params.get("before_limit", 24)) except (ValueError, TypeError): before_limit = 24 try: after_limit = int(request.query_params.get("after_limit", 10)) except (ValueError, TypeError): after_limit = 10 # Для обратной совместимости с limit параметром if ( "limit" in request.query_params and "before_limit" not in request.query_params ): try: total_limit = min(int(request.query_params.get("limit", 30)), 100) except (ValueError, TypeError): total_limit = 30 before_limit = int(total_limit * 0.8) # 80% на контекст after_limit = total_limit - before_limit # 20% на новые before_limit = min(max(before_limit, 0), 100) after_limit = min(max(after_limit, 0), 100 - before_limit) queryset = ( chat.messages.filter(is_deleted=False) .select_related("author", "reply_to", "reply_to__author", "poll") .prefetch_related( "attachments", "reactions", "reactions__user", "poll__options", Prefetch( "chat__read_states", queryset=ChatReadState.objects.select_related("user"), ), ) ) # Если нет around_id, пытаемся получить last_read_message_id if not around_id: read_state = ChatReadState.objects.filter( chat=chat, user=request.user ).first() if read_state and read_state.last_read_message_id: around_id = read_state.last_read_message_id # Если все еще нет around_id - возвращаем последние сообщения if not around_id: fallback_limit = before_limit + after_limit messages = list( queryset.order_by("-created_at", "-id")[: fallback_limit + 1] ) has_more_before = len(messages) > fallback_limit if has_more_before: messages = messages[:fallback_limit] messages.reverse() # Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read # Frontend при следующей загрузке запросит вокруг ЭТОГО сообщения if messages: self._auto_mark_read(chat, request.user, messages) return Response( { "messages": [ serialize_message( m, user=request.user, include_linked_tasks=True, ) for m in messages ], "messages_count": len(messages), "anchor_id": None, "anchor_index": 0, "has_more_before": has_more_before, "has_more_after": False, } ) # Определяем, это ID сообщения или timestamp anchor_msg = None try: around_value = int(around_id) # Если это большое число (> 1 миллиард) - вероятно timestamp в миллисекундах if around_value > 1_000_000_000: # Конвертируем timestamp из миллисекунд в seconds timestamp_seconds = around_value / 1000 anchor_dt = datetime.fromtimestamp(timestamp_seconds, tz=dt_tz.utc) # Ищем ближайшее сообщение к этому timestamp (ДО или на момент) anchor_msg = ( queryset.filter(created_at__lte=anchor_dt) .order_by("-created_at") .first() ) # Если не нашли до этого времени, берем первое после if not anchor_msg: anchor_msg = ( queryset.filter(created_at__gte=anchor_dt) .order_by("created_at") .first() ) logger.debug( "Chat message anchor resolved by timestamp", extra={ "event": "chat.message_anchor.resolved", "chat_id": chat.id, "anchor_type": "timestamp", "anchor_found": bool(anchor_msg), "message_id": getattr(anchor_msg, "id", None), }, ) else: # Это обычный message_id try: anchor_msg = queryset.get(pk=around_value) logger.debug( "Chat message anchor resolved by ID", extra={ "event": "chat.message_anchor.resolved", "chat_id": chat.id, "anchor_type": "message_id", "message_id": around_value, }, ) except Message.DoesNotExist: logger.warning( "Chat message anchor not found", extra={ "event": "chat.message_anchor.not_found", "chat_id": chat.id, "anchor_type": "message_id", "message_id": around_value, }, ) except (ValueError, TypeError) as error: logger.warning( "Chat message anchor format is invalid", extra={ "event": "chat.message_anchor.invalid", "chat_id": chat.id, "anchor_type": "unknown", "error_type": type(error).__name__, }, ) if not anchor_msg: # Если не найдено - возвращаем последние сообщения fallback_limit = before_limit + after_limit messages = list( queryset.order_by("-created_at", "-id")[: fallback_limit + 1] ) has_more_before = len(messages) > fallback_limit if has_more_before: messages = messages[:fallback_limit] messages.reverse() # Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read # Frontend при следующей загрузке запросит вокруг ЭТОГО сообщения if messages: self._auto_mark_read(chat, request.user, messages) return Response( { "messages": [ serialize_message( m, user=request.user, include_linked_tasks=True, ) for m in messages ], "messages_count": len(messages), "anchor_id": None, "anchor_index": 0, "has_more_before": has_more_before, "has_more_after": False, } ) # Асимметричная загрузка: больше контекста, меньше новых before = list( queryset.filter(created_at__lt=anchor_msg.created_at).order_by( "-created_at" )[:before_limit] ) before.reverse() after = list( queryset.filter(created_at__gte=anchor_msg.created_at).order_by( "created_at" )[: after_limit + 1] ) # +1 для включения anchor messages = before + after anchor_index = len(before) # Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read (24 контекста + 6 новых) # Frontend при следующей загрузке запросит вокруг последнего из ЭТИХ сообщений # НЕ отмечается последнее в чате, а последнее из того что мы загрузили! if messages: self._auto_mark_read(chat, request.user, messages) return Response( { "messages": [ serialize_message( m, user=request.user, include_linked_tasks=True, ) for m in messages ], "messages_count": len(messages), "anchor_id": anchor_msg.id, "anchor_index": anchor_index, "has_more_before": len(before) >= before_limit, "has_more_after": len(after) > after_limit, } ) @action(detail=True, methods=["get"], url_path="search-messages") def search_messages(self, request, pk=None): """Поиск сообщений внутри одного чата с пагинацией и сниппетами.""" chat = self.get_object() if not user_can_access_chat(chat, request.user): return Response( {"error": "Access denied"}, status=status.HTTP_403_FORBIDDEN ) query = (request.query_params.get("q") or "").strip() try: limit = min(max(int(request.query_params.get("limit", 20)), 1), 100) except (ValueError, TypeError): limit = 20 try: offset = max(int(request.query_params.get("offset", 0)), 0) except (ValueError, TypeError): offset = 0 if len(query) < 2: return Response( { "query": query, "count": 0, "offset": offset, "next_offset": None, "results": [], } ) queryset = ( chat.messages.filter( is_deleted=False, ) .select_related("author") .prefetch_related("attachments") .filter( Q(content__icontains=query) | Q(attachments__file_name__icontains=query) ) .distinct() .order_by("-created_at", "-id") ) total_count = queryset.count() matches = list(queryset[offset : offset + limit]) next_offset = offset + limit if (offset + limit) < total_count else None results = [] for message in matches: author = message.author author_name = ( f"{getattr(author, 'last_name', '')} {getattr(author, 'first_name', '')}".strip() or getattr(author, "username", "") or "Сотрудник" ) attachments = list(message.attachments.all()) results.append( { "message_id": message.id, "content": message.content or "", "snippet": _build_message_search_snippet(message, query), "author_name": author_name, "created_at": message.created_at, "attachments_count": len(attachments), "has_attachments": bool(attachments), } ) return Response( { "query": query, "count": total_count, "offset": offset, "next_offset": next_offset, "results": results, } ) @action(detail=True, methods=["post"], url_path="mark-read") def mark_read(self, request, pk=None): """ Ручная отметка прочитанных сообщений (опционально). Обычно используется автоматическая отметка при GET /messages-around/. Этот endpoint полезен для точной отметки через IntersectionObserver. Параметры: - message_id: ID последнего прочитанного сообщения (рекомендуется) - upto_ts: timestamp (legacy, используйте message_id) """ chat = self.get_object() logger.info( "Chat read marker update started", extra={ "event": "chat.read_marker.update_started", "user_id": request.user.id, "chat_id": chat.id, }, ) # Основная логика: поддерживаем message_id message_id = request.data.get("message_id") upto_ts = request.data.get("upto_ts") if not message_id and not upto_ts: last_msg = ( chat.messages.filter(is_deleted=False).order_by("-created_at").first() ) if not last_msg: return Response({"ok": True, "last_read_message_id": None}) self._auto_mark_read(chat, request.user, [last_msg]) return Response( { "ok": True, "last_read_message_id": last_msg.id, "deprecated": True, "message": "message_id was not provided, latest message was marked as read.", } ) if message_id: try: message = chat.messages.get(pk=int(message_id), is_deleted=False) # Используем новую логику через _auto_mark_read self._auto_mark_read(chat, request.user, [message]) return Response({"ok": True, "last_read_message_id": message.id}) except (Message.DoesNotExist, ValueError) as error: logger.warning( "Chat read marker update rejected", extra={ "event": "chat.read_marker.rejected", "chat_id": chat.id, "reason": "invalid_message_id", "error_type": type(error).__name__, }, ) return Response( {"error": f"Message {message_id} not found"}, status=status.HTTP_404_NOT_FOUND, ) # Fallback: если передан upto_ts (старая логика) if upto_ts: try: ts_value = float(upto_ts) if ts_value > 10000000000: ts_value = ts_value / 1000 timestamp = datetime.fromtimestamp(ts_value, tz=dt_tz.utc) # Находим последнее сообщение до этого времени last_msg = ( chat.messages.filter(created_at__lte=timestamp, is_deleted=False) .order_by("-created_at") .first() ) if last_msg: self._auto_mark_read(chat, request.user, [last_msg]) return Response( { "ok": True, "last_read_message_id": last_msg.id, "deprecated": True, "message": "Using upto_ts is deprecated. Use message_id instead.", } ) else: return Response( {"error": "No messages found before specified timestamp"}, status=status.HTTP_404_NOT_FOUND, ) except (ValueError, OSError) as error: logger.warning( "Chat read marker update rejected", extra={ "event": "chat.read_marker.rejected", "chat_id": chat.id, "reason": "invalid_timestamp", "error_type": type(error).__name__, }, ) return Response( {"error": "Invalid timestamp format"}, status=status.HTTP_400_BAD_REQUEST, ) # Ни message_id ни upto_ts не переданы return Response( {"error": "Either message_id or upto_ts required"}, status=status.HTTP_400_BAD_REQUEST, ) class MessageViewSet(viewsets.ModelViewSet): """ ViewSet для управления сообщениями create: POST /api/v1/communications/messages/ - создать сообщение (с файлами) update: PUT/PATCH /api/v1/communications/messages/{id}/ - редактировать destroy: DELETE /api/v1/communications/messages/{id}/ - удалить Actions: - react: POST /api/v1/communications/messages/{id}/react/ - добавить реакцию - unreact: POST /api/v1/communications/messages/{id}/unreact/ - убрать реакцию - forward: POST /api/v1/communications/messages/forward/ - переслать сообщения - bulk_delete: POST /api/v1/communications/messages/bulk_delete/ - массовое удаление - upload: POST /api/v1/communications/messages/upload/ - загрузить с вложениями """ queryset = Message.objects.none() permission_classes = [MessagePermission] parser_classes = [MultiPartParser, FormParser, JSONParser] def get_serializer_class(self): if self.action in ["create", "upload"]: return MessageCreateSerializer elif self.action == "update" or self.action == "partial_update": return MessageEditSerializer elif self.action == "list": return MessageListSerializer return MessageDetailSerializer def get_queryset(self): """ Сообщения доступные пользователю Включает сообщения из чатов: - где user в participants (прямое участие) - где user в ChatMembership (роли) - где include_all_users=True (открытые чаты) - context-based чаты (type=comments с context_object) Детальная проверка доступа выполняется через MessagePermission """ user = self.request.user # MIGRATION: Чаты где пользователь состоит через memberships (убрали # participants) accessible_chats = Chat.objects.filter( Q(memberships__user=user, memberships__is_active=True) | Q(participants=user) | Q(include_all_users=True) ).distinct() # Context-based чаты (комментарии к постам и т.д.) # Проверка доступа к context_object будет в permissions context_based_chats = Chat.objects.filter( Q(type="comments") & ~Q(context_object_id=None) ).distinct() # Объединяем оба набора all_chats = accessible_chats | context_based_chats return ( Message.objects.filter(chat__in=all_chats) .select_related("author", "chat", "reply_to", "reply_to__author", "poll") .prefetch_related( "attachments", "reactions", "reactions__user", "poll__options" ) .order_by("-created_at") ) @action(detail=False, methods=["post"]) def upload(self, request): """ Загрузка сообщения с вложениями POST /api/v1/communications/messages/upload/ """ chat_id = request.data.get("chat_id") content = request.data.get("content", "").strip() reply_to_id = request.data.get("reply_to") or request.data.get("reply_to_id") mentioned_user_ids = parse_mentioned_user_ids( request.data.get("mentioned_user_ids") ) # Валидация чата try: chat = Chat.objects.get(pk=chat_id) except Chat.DoesNotExist: return Response( {"error": "Chat not found"}, status=status.HTTP_404_NOT_FOUND ) if not user_can_access_chat(chat, request.user): return Response( {"error": "Access denied"}, status=status.HTTP_403_FORBIDDEN ) reply_to_message = None if reply_to_id: try: reply_to_message = chat.messages.get( pk=int(reply_to_id), is_deleted=False ) except (Message.DoesNotExist, ValueError): return Response( {"error": "Reply target not found"}, status=status.HTTP_404_NOT_FOUND, ) # Получаем файлы files = [] for key in request.FILES: if key.startswith("file_"): files.append(request.FILES[key]) # Валидация: либо текст, либо файлы if not content and not files: return Response( {"error": "Message must have either content or attachments"}, status=status.HTTP_400_BAD_REQUEST, ) # Создаем сообщение with transaction.atomic(): message = Message.objects.create( chat=chat, author=request.user, content=content, mentions=build_message_mentions( chat, request.user, content, mentioned_user_ids, ), reply_to=reply_to_message, has_attachments=len(files) > 0, ) # Сохраняем файлы for file in files: MessageAttachment.objects.create( message=message, file=file, file_name=file.name, file_size=file.size, mime_type=file.content_type or "application/octet-stream", file_type=self._detect_file_type(file.content_type), ) # Перезагружаем с prefetch message = ( Message.objects.select_related( "author", "reply_to", "reply_to__author", "poll", "chat" ) .prefetch_related( "attachments", "reactions", "reactions__user", Prefetch( "chat__read_states", queryset=ChatReadState.objects.select_related("user"), ), ) .get(pk=message.id) ) # Отправляем через WebSocket channel_layer = get_channel_layer() payload = serialize_message(message) response_payload = serialize_message( message, user=request.user, include_linked_tasks=True, ) async_to_sync(channel_layer.group_send)( f"chat_{chat.id}", {"type": "chat.message", "chat_id": chat.id, "payload": payload}, ) return Response( {"ok": True, "message": response_payload}, status=status.HTTP_201_CREATED ) @action(detail=False, methods=["post"], url_path="upload-temp") def upload_temp(self, request): """ Временная загрузка файлов для редактирования сообщения POST /api/v1/communications/messages/upload-temp/ Создает MessageAttachment без привязки к сообщению (message=null) """ # Получаем файлы files = [] for key in request.FILES: if key.startswith("file_"): files.append(request.FILES[key]) if not files: return Response( {"error": "No files provided"}, status=status.HTTP_400_BAD_REQUEST ) # Создаем attachments БЕЗ привязки к сообщению attachment_ids = [] with transaction.atomic(): for file in files: attachment = MessageAttachment.objects.create( message=None, # Без сообщения! file=file, file_name=file.name, file_size=file.size, mime_type=file.content_type or "application/octet-stream", file_type=self._detect_file_type(file.content_type), ) attachment_ids.append(attachment.id) return Response( {"ok": True, "attachment_ids": attachment_ids}, status=status.HTTP_201_CREATED, ) def update(self, request, *args, **kwargs): """Редактирование сообщения""" partial = kwargs.pop("partial", False) instance = self.get_object() # Проверка прав if instance.author != request.user: return Response( {"error": "You can only edit your own messages"}, status=status.HTTP_403_FORBIDDEN, ) # Проверка типа чата if instance.chat.type == "announcement": return Response( {"error": "Editing is not allowed in announcements"}, status=status.HTTP_403_FORBIDDEN, ) serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) new_content = serializer.validated_data.get("content", "").strip() previous_mention_ids = get_mentioned_user_ids(instance.mentions) requested_mention_ids = serializer.validated_data.get( "mentioned_user_ids", list(previous_mention_ids), ) next_mentions = build_message_mentions( instance.chat, request.user, new_content, requested_mention_ids, ) added_mention_ids = get_mentioned_user_ids(next_mentions) - previous_mention_ids removed_mention_ids = previous_mention_ids - get_mentioned_user_ids(next_mentions) existing_attachment_ids = serializer.validated_data.get( "existing_attachment_ids" ) # Валидация вложений current_attachments_count = instance.attachments.count() will_have_attachments = ( existing_attachment_ids is not None and len(existing_attachment_ids) > 0 ) or (existing_attachment_ids is None and current_attachments_count > 0) if not new_content and not will_have_attachments: return Response( {"error": "Message must have either content or attachments"}, status=status.HTTP_400_BAD_REQUEST, ) # Сохраняем историю редактирования в отдельную таблицу from communications.models import MessageEditHistory MessageEditHistory.objects.create( message=instance, previous_content=instance.content, edited_by=request.user ) instance.content = new_content instance.mentions = next_mentions instance.is_edited = True instance.edited_at = timezone.now() # Управление вложениями if existing_attachment_ids is not None: from communications.models import MessageAttachment current_ids = set(str(att.id) for att in instance.attachments.all()) keep_ids = set(str(id) for id in existing_attachment_ids) ids_to_remove = current_ids - keep_ids ids_to_add = keep_ids - current_ids # Удаляем attachments которых нет в списке if ids_to_remove: attachments_to_delete = instance.attachments.filter( id__in=ids_to_remove ) for att in attachments_to_delete: if att.file and att.file.storage.exists(att.file.name): att.file.delete(save=False) attachments_to_delete.delete() logger.info( "Message attachments removed", extra={ "event": "chat.message.attachments_removed", "message_id": instance.id, "attachment_count": len(ids_to_remove), }, ) # Переносим новые attachments из других сообщений к этому if ids_to_add: attachments_to_move = MessageAttachment.objects.filter( id__in=ids_to_add ) updated_count = attachments_to_move.update(message=instance) logger.info( "Message attachments moved", extra={ "event": "chat.message.attachments_moved", "message_id": instance.id, "attachment_count": updated_count, }, ) instance.save() update_unread_mention_counters( instance, added_user_ids=added_mention_ids, removed_user_ids=removed_mention_ids, ) if added_mention_ids: from communications.notifications.handlers import notify_message_mentions notify_message_mentions(instance, recipient_ids=added_mention_ids) # ВАЖНО: Очищаем кэш attachments перед подсчетом instance.refresh_from_db() # Обновляем has_attachments (заново считаем из БД) from communications.models import MessageAttachment instance.has_attachments = ( MessageAttachment.objects.filter(message=instance).count() > 0 ) instance.save(update_fields=["has_attachments"]) # Перезагружаем с нуля чтобы получить обновленные attachments # Используем новый запрос, чтобы избежать кэширования instance = ( 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=instance.id) ) from notifications.push_lifecycle import replace_message_notifications replace_message_notifications(instance) # WebSocket уведомление channel_layer = get_channel_layer() payload = serialize_message(instance) response_payload = serialize_message( instance, user=request.user, include_linked_tasks=True, ) group_name = f"chat_{instance.chat_id}" async_to_sync(channel_layer.group_send)( group_name, { "type": "chat.message_edited", "chat_id": instance.chat_id, "payload": payload, }, ) return Response(response_payload) def destroy(self, request, *args, **kwargs): """Мягкое удаление сообщения""" instance = self.get_object() if instance.author != request.user: return Response( {"error": "You can only delete your own messages"}, status=status.HTTP_403_FORBIDDEN, ) soft_delete_message(instance, deleted_by=request.user) _broadcast_message_deleted( instance.chat_id, instance.id, instance.author_id, ) return Response({"ok": True}, status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["post"]) def react(self, request, pk=None): """Добавить реакцию""" message = self.get_object() serializer = ReactionSerializer(data=request.data) serializer.is_valid(raise_exception=True) emoji = serializer.validated_data["emoji"] # Создаем или получаем реакцию (по message + user, т.к. unique_together) reaction, created = MessageReaction.objects.get_or_create( message=message, user=request.user, defaults={"emoji": emoji} ) # Если реакция уже существует, обновляем emoji if not created: old_emoji = reaction.emoji if old_emoji == emoji: return Response( {"error": "Reaction already exists"}, status=status.HTTP_400_BAD_REQUEST, ) reaction.emoji = emoji reaction.save(update_fields=["emoji"]) reactions_summary = message.get_reactions_summary() # WebSocket уведомление channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( f"chat_{message.chat_id}", { "type": "chat.reaction_added", "chat_id": message.chat_id, "message_id": message.id, "user_id": request.user.id, "emoji": emoji, "reactions_summary": reactions_summary, }, ) logger.info( "Message reaction saved", extra={ "event": "chat.reaction.saved", "chat_id": message.chat_id, "message_id": message.id, "reaction_id": reaction.id, "user_id": request.user.id, "reaction_created": created, }, ) return Response({"ok": True, "reactions_summary": reactions_summary}) @action(detail=True, methods=["post"]) def unreact(self, request, pk=None): """Убрать реакцию""" message = self.get_object() serializer = ReactionSerializer(data=request.data) serializer.is_valid(raise_exception=True) emoji = serializer.validated_data["emoji"] deleted_count = MessageReaction.objects.filter( message=message, user=request.user, emoji=emoji ).delete()[0] if deleted_count == 0: return Response( {"error": "Reaction not found"}, status=status.HTTP_404_NOT_FOUND ) reactions_summary = message.get_reactions_summary() # WebSocket уведомление channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( f"chat_{message.chat_id}", { "type": "chat.reaction_removed", "chat_id": message.chat_id, "message_id": message.id, "user_id": request.user.id, "emoji": emoji, "reactions_summary": reactions_summary, }, ) logger.info( "Message reaction removed", extra={ "event": "chat.reaction.removed", "chat_id": message.chat_id, "message_id": message.id, "user_id": request.user.id, "deleted_count": deleted_count, }, ) return Response({"ok": True, "reactions_summary": reactions_summary}) @action(detail=False, methods=["post"]) def forward(self, request): """Переслать сообщения в другой чат""" serializer = ForwardMessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) message_ids = serializer.validated_data["message_ids"] target_chat_id = serializer.validated_data["target_chat_id"] # Получаем целевой чат try: target_chat = Chat.objects.get(pk=target_chat_id) except Chat.DoesNotExist: return Response( {"error": "Target chat not found"}, status=status.HTTP_404_NOT_FOUND ) if not user_can_access_chat(target_chat, request.user): return Response( {"error": "Access denied to target chat"}, status=status.HTTP_403_FORBIDDEN, ) # Пересылаем сообщения forwarded_ids = [] with transaction.atomic(): messages = Message.objects.filter(id__in=message_ids).select_related( "author" ) for original_msg in messages: # Создаём пересланное сообщение forwarded = Message.objects.create( chat=target_chat, author=request.user, content=original_msg.content, is_forwarded=True, ) # Создаём метаданные пересылки from communications.models import MessageForwardMetadata MessageForwardMetadata.objects.create( message=forwarded, original_message=original_msg, original_author=original_msg.author, original_chat=original_msg.chat, original_chat_name=original_msg.chat.name or "Unknown", original_created_at=original_msg.created_at, forwarded_by=request.user, forward_count=1, ) forwarded_ids.append(forwarded.id) return Response( { "ok": True, "forwarded_count": len(forwarded_ids), "forwarded_ids": forwarded_ids, } ) @action(detail=False, methods=["post"], url_path="bulk-delete") def bulk_delete(self, request): """Массовое удаление сообщений""" serializer = BulkDeleteSerializer(data=request.data) serializer.is_valid(raise_exception=True) message_ids = serializer.validated_data["message_ids"] # Удаляем только свои сообщения и корректируем денормализованные # счетчики непрочитанных для каждого удаленного сообщения. messages = list( Message.objects.filter( id__in=message_ids, author=request.user, is_deleted=False, ) ) deleted_count = 0 for message in messages: if not soft_delete_message(message, deleted_by=request.user): continue deleted_count += 1 _broadcast_message_deleted( message.chat_id, message.id, message.author_id, ) return Response({"ok": True, "deleted_count": deleted_count}) def _detect_file_type(self, mime_type): """Определение типа файла по MIME""" if not mime_type: return "file" if mime_type.startswith("image/"): return "image" elif mime_type.startswith("video/"): return "video" elif mime_type.startswith("audio/"): return "audio" elif "pdf" in mime_type: return "pdf" elif any( x in mime_type for x in ["document", "word", "text", "sheet", "excel"] ): return "document" else: return "file" class PollViewSet(viewsets.ModelViewSet): """ ViewSet для управления голосованиями create: POST /api/v1/communications/polls/ - создать голосование retrieve: GET /api/v1/communications/polls/{id}/ - детали голосования Actions: - vote: POST /api/v1/communications/polls/{id}/vote/ - проголосовать - close: POST /api/v1/communications/polls/{id}/close/ - закрыть голосование - results: GET /api/v1/communications/polls/{id}/results/ - результаты """ 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 get_queryset(self): queryset = super().get_queryset() user = self.request.user if user.is_superuser: return queryset 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) ) 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) return queryset.filter(accessible_filter).distinct() 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) 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) ) 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, }, ) return Response( { "poll": serialize_poll(poll, user=request.user), "message": response_message, }, status=status.HTTP_201_CREATED, ) 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}", { "type": "poll.update", "chat_id": poll.message.chat_id, "payload": { "poll_id": poll.id, "message_id": poll.message_id, "results": results, }, }, ) 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.author != request.user and not request.user.is_superuser: return Response( {"error": "Завершить голосование может только автор"}, status=status.HTTP_403_FORBIDDEN, ) poll.close() self._broadcast_update(poll) return Response(serialize_poll(poll, user=request.user)) @action(detail=True, methods=["get", "post"]) def results(self, request, pk=None): """Получить результаты голосования""" poll = self.get_object() return Response(serialize_poll(poll, user=request.user))