Игорь Надеин 4f6244ab1f
Some checks failed
Deploy production / deploy (push) Failing after 2s
Fix CORP runtime errors and harden deployment and media access
2026-09-10 16:40:08 +03:00

2233 lines
85 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from communications.media_delivery import chat_file_response
from communications.serialization import (
serialize_message,
serialize_poll,
)
from ..mentions import (
build_message_mentions,
filter_mentionable_users,
get_mentionable_users,
mention_display_name,
parse_mentioned_user_ids,
update_unread_mention_counters,
)
from ..mentions import (
mentioned_user_ids as get_mentioned_user_ids,
)
from ..models import (
Chat,
ChatMembership,
ChatReadState,
ChatUserSettings,
Message,
MessageAttachment,
MessageReaction,
Poll,
)
from ..polls import PollActionError, cast_poll_vote
from ..services import (
ChatNotificationReadService,
ChatReadMarkerService,
soft_delete_message,
update_chat_notification_preferences,
)
from ..utils import _coerce_ts, user_can_access_chat
from .permissions import ChatPermission, MessagePermission
from .serializers import (
BulkDeleteSerializer,
ChatDetailSerializer,
ChatListSerializer,
ChatNotificationPreferencesUpdateSerializer,
ChatUserSettingsSerializer,
ForwardMessageSerializer,
MessageCreateSerializer,
MessageDetailSerializer,
MessageEditSerializer,
MessageListSerializer,
PollSerializer,
ReactionSerializer,
)
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 при прочтении.
Уведомления чата читаются той же HTTP-операцией, даже если чат пуст.
"""
with transaction.atomic():
if messages:
ChatReadMarkerService().mark_read(
chat=chat,
user=user,
message=messages[-1],
)
ChatNotificationReadService().mark_chat_notifications_as_read(
user=user,
chat_id=chat.id,
)
@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 при следующей загрузке запросит вокруг ЭТОГО сообщения
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 при следующей загрузке запросит вокруг ЭТОГО сообщения
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:
self._auto_mark_read(chat, request.user, [])
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
"""
if self.action in ("attachment", "attachment_thumbnail"):
# Media uses object-level rules plus an explicit current chat ACL.
# A participant-list prefilter misses global and contextual chats.
return Message.objects.select_related("chat", "author")
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)
| Q(type="global")
| Q(created_by=user)
).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
if self.action == "list":
# DRF does not call object permissions for list rows. Apply chat ACL
# before pagination, otherwise context chats and revoked legacy
# participants could leak message contents and result counts.
chat_id = self.request.query_params.get(
"chat_id"
) or self.request.query_params.get("chat")
if chat_id is not None:
try:
chat_id = int(chat_id)
if chat_id <= 0:
raise ValueError
except (TypeError, ValueError) as exc:
raise ValidationError(
{"chat_id": "Ожидается положительный ID чата"}
) from exc
all_chats = all_chats.filter(pk=chat_id)
allowed_chat_ids = [
chat.pk
for chat in all_chats.order_by("pk").distinct().iterator(chunk_size=256)
if user_can_access_chat(chat, user)
]
all_chats = Chat.objects.filter(pk__in=allowed_chat_ids)
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")
)
def _attachment_response(self, request, attachment_id, *, thumbnail=False):
message = self.get_object()
if message.is_deleted or not user_can_access_chat(message.chat, request.user):
raise Http404("Вложение недоступно")
attachment = get_object_or_404(
MessageAttachment, pk=attachment_id, message=message
)
file = attachment.thumbnail if thumbnail else attachment.file
if not file:
raise Http404("Вложение недоступно")
try:
return chat_file_response(
file,
filename=file.name.rsplit("/", 1)[-1]
if thumbnail
else attachment.file_name,
)
except (OSError, ValueError) as exc:
raise Http404("Вложение недоступно") from exc
@extend_schema(
parameters=[
OpenApiParameter("attachment_id", OpenApiTypes.INT, OpenApiParameter.PATH)
],
responses={(200, "application/octet-stream"): OpenApiTypes.BINARY},
)
@action(
detail=True, methods=["get"], url_path=r"attachments/(?P<attachment_id>\d+)"
)
def attachment(self, request, pk=None, attachment_id=None):
"""Download only after current message/chat ACL checks, including comments."""
return self._attachment_response(request, attachment_id)
@extend_schema(
parameters=[
OpenApiParameter("attachment_id", OpenApiTypes.INT, OpenApiParameter.PATH)
],
responses={
(200, "image/jpeg"): OpenApiTypes.BINARY,
(200, "image/png"): OpenApiTypes.BINARY,
},
)
@action(
detail=True,
methods=["get"],
url_path=r"attachments/(?P<attachment_id>\d+)/thumbnail",
)
def attachment_thumbnail(self, request, pk=None, attachment_id=None):
return self._attachment_response(request, attachment_id, thumbnail=True)
@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,
uploaded_by=request.user,
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, # Без сообщения!
uploaded_by=request.user,
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,
)
@transaction.atomic
def update(self, request, *args, **kwargs):
"""Редактирование сообщения"""
partial = kwargs.pop("partial", False)
instance = self.get_object()
# Serialize edits and temporary attachment claims. Do not lock the
# nullable joins in the view's prefetched queryset on PostgreSQL.
instance = Message.objects.select_for_update().get(pk=instance.pk)
if instance.is_deleted or not user_can_access_chat(instance.chat, request.user):
raise PermissionDenied("Message is not available for editing")
# Проверка прав
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", instance.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_ids = set(instance.attachments.values_list("id", flat=True))
keep_ids = (
set(existing_attachment_ids)
if existing_attachment_ids is not None
else current_ids
)
ids_to_add = keep_ids - current_ids
if ids_to_add:
# Existing attached files stay in their message, even for the same
# author. Only this user's unclaimed uploads may be claimed. Legacy
# temporary rows have no owner and deliberately fail closed.
candidates = list(
MessageAttachment.objects.select_for_update()
.filter(pk__in=ids_to_add)
.order_by("pk")
)
if len(candidates) != len(ids_to_add) or any(
att.message_id is not None or att.uploaded_by_id != request.user.pk
for att in candidates
):
raise ValidationError(
{"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:
ids_to_remove = current_ids - keep_ids
# Удаляем attachments которых нет в списке
if ids_to_remove:
attachments_to_delete = instance.attachments.filter(
id__in=ids_to_remove
)
for att in attachments_to_delete:
# Storage deletion cannot roll back with SQL. Schedule it
# only after all changes have committed successfully.
for file in (att.file, att.thumbnail):
if file:
transaction.on_commit(
lambda storage=file.storage, name=file.name: (
storage.delete(name)
),
robust=True,
)
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),
},
)
# Claim only the locked, validated uploads owned by this user.
if ids_to_add:
attachments_to_move = MessageAttachment.objects.filter(
id__in=ids_to_add, message__isnull=True, uploaded_by=request.user
)
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 (заново считаем из БД)
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
)
import rules
if not user_can_access_chat(target_chat, request.user) or not rules.test_rule(
"communications.send_message", request.user, target_chat
):
return Response(
{"error": "Access denied to target chat"},
status=status.HTTP_403_FORBIDDEN,
)
# Пересылаем сообщения
forwarded_ids = []
with transaction.atomic():
messages = list(
Message.objects.filter(id__in=message_ids).select_related(
"author", "chat"
)
)
if len(messages) != len(set(message_ids)):
raise Http404("Message not available")
if any(
message.is_deleted
or not user_can_access_chat(message.chat, request.user)
for message in messages
):
raise PermissionDenied("Access denied to source message")
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))