2089 lines
78 KiB
Python
2089 lines
78 KiB
Python
"""
|
||
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 (
|
||
ChatReadMarkerService,
|
||
soft_delete_message,
|
||
update_chat_notification_preferences,
|
||
)
|
||
from ..utils import _coerce_ts, user_can_access_chat
|
||
from communications.serialization import (
|
||
serialize_message,
|
||
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.visible_in_ui()
|
||
.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.visible_in_ui().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
|
||
|
||
ChatReadMarkerService().mark_read(
|
||
chat=chat,
|
||
user=user,
|
||
message=messages[-1],
|
||
)
|
||
|
||
@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))
|