1823 lines
70 KiB
Python
1823 lines
70 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,
|
||
ForwardMessageSerializer,
|
||
MessageCreateSerializer,
|
||
MessageDetailSerializer,
|
||
MessageEditSerializer,
|
||
MessageListSerializer,
|
||
PollSerializer,
|
||
ReactionSerializer,
|
||
)
|
||
from ..models import (
|
||
Chat,
|
||
ChatMembership,
|
||
ChatReadState,
|
||
ChatUserSettings,
|
||
Message,
|
||
MessageAttachment,
|
||
MessageReaction,
|
||
Poll,
|
||
PollVote,
|
||
)
|
||
from ..utils import _coerce_ts, user_can_access_chat
|
||
from communications.serialization import serialize_message
|
||
|
||
logger = logging.getLogger(__name__)
|
||
User = get_user_model()
|
||
|
||
|
||
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' исключены из списка (list action),
|
||
но доступны по прямой ссылке (retrieve action).
|
||
"""
|
||
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) # Создатель всегда видит свои чаты
|
||
).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').order_by('-created_at')[:1],
|
||
to_attr='_prefetched_last_message'
|
||
),
|
||
).distinct()
|
||
|
||
# Исключаем чаты-комментарии из общего списка
|
||
# Они доступны только через прямой запрос (retrieve) или через контекст поста
|
||
if self.action == 'list':
|
||
queryset = queryset.exclude(type='comments')
|
||
|
||
return queryset.order_by('-created_at')
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание чата"""
|
||
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
|
||
)
|
||
|
||
# Добавляем остальных участников из запроса
|
||
participant_ids = request.data.get('participants', [])
|
||
if participant_ids:
|
||
for user_id in participant_ids:
|
||
if user_id == request.user.id:
|
||
continue
|
||
try:
|
||
participant = User.objects.get(id=user_id)
|
||
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(
|
||
f"User {user_id} not found when creating chat {
|
||
chat.id}")
|
||
|
||
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):
|
||
"""Включить/выключить уведомления"""
|
||
chat = self.get_object()
|
||
settings, created = ChatUserSettings.objects.get_or_create(
|
||
chat=chat,
|
||
user=request.user
|
||
)
|
||
settings.notifications_enabled = not settings.notifications_enabled
|
||
settings.save()
|
||
|
||
return Response({
|
||
'ok': True,
|
||
'notifications_enabled': settings.notifications_enabled
|
||
})
|
||
|
||
@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
|
||
)
|
||
|
||
try:
|
||
user_to_add = User.objects.get(pk=user_id)
|
||
except User.DoesNotExist:
|
||
return Response(
|
||
{'error': 'User not found'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
|
||
# 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.warning(
|
||
f"[change_role] User {
|
||
request.user.id} trying to change role in chat {
|
||
chat.id}. " f"chat.created_by={
|
||
chat.created_by.id if chat.created_by else None}, " f"request.user.id={
|
||
request.user.id}, " f"can_change={can_change}")
|
||
|
||
if not can_change:
|
||
return Response(
|
||
{'error': 'Permission denied. Only chat owner can change roles.'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
try:
|
||
user_to_change = 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_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.warning(
|
||
f"[change_role] membership found: created={created}, " f"user_id={
|
||
user_to_change.id}, old_role={
|
||
membership.role}, new_role={new_role}, " f"is_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.warning(
|
||
f"[change_role] after save: user_id={
|
||
user_to_change.id}, " f"old_role={old_role}, new_role={
|
||
membership.role}, " f"old_is_active={old_is_active}, new_is_active={
|
||
membership.is_active}, " f"saved_correctly={
|
||
membership.role == new_role and membership.is_active}")
|
||
|
||
return Response({
|
||
'ok': True,
|
||
'message': f'User role changed to {new_role}',
|
||
'membership': {
|
||
'user_id': membership.user_id,
|
||
'role': membership.role,
|
||
'can_send_messages': membership.can_send_messages,
|
||
'can_add_members': membership.can_add_members,
|
||
'can_remove_members': membership.can_remove_members,
|
||
'can_pin_messages': membership.can_pin_messages,
|
||
'can_manage_members': membership.can_manage_members
|
||
}
|
||
})
|
||
|
||
def _auto_mark_read(self, chat, user, messages):
|
||
"""
|
||
Автоматически отмечает последнее загруженное сообщение как прочитанное.
|
||
Pragmatic подход: "загрузил = прочитал" с ограничением количества новых сообщений.
|
||
|
||
Защита от откатов: обновляет только если новое сообщение НОВЕЕ текущего.
|
||
Денормализация: обнуляет unread_count при прочтении.
|
||
"""
|
||
if not messages:
|
||
return
|
||
|
||
last_message = messages[-1]
|
||
|
||
read_state, created = ChatReadState.objects.get_or_create(
|
||
chat=chat,
|
||
user=user,
|
||
defaults={
|
||
'last_read_message': last_message,
|
||
'unread_count': 0 # Прочитали → обнуляем
|
||
}
|
||
)
|
||
|
||
if created:
|
||
logger.info(
|
||
f"[auto_mark_read] Created: user={
|
||
user.id}, chat={
|
||
chat.id}, msg={
|
||
last_message.id}")
|
||
self._send_marked_read_event(user.id, chat.id, last_message.id)
|
||
return
|
||
|
||
# Защита от откатов: только если НОВЕЕ
|
||
if read_state.last_read_message_id and last_message.id <= read_state.last_read_message_id:
|
||
logger.debug(
|
||
f"[auto_mark_read] Skip: {
|
||
last_message.id} <= {
|
||
read_state.last_read_message_id}")
|
||
return
|
||
|
||
read_state.last_read_message = last_message
|
||
read_state.unread_count = 0 # Прочитали → обнуляем
|
||
read_state.save(
|
||
update_fields=[
|
||
'last_read_message',
|
||
'unread_count',
|
||
'updated_at'])
|
||
|
||
logger.info(
|
||
f"[auto_mark_read] Updated: user={
|
||
user.id}, chat={
|
||
chat.id}, msg={
|
||
last_message.id}")
|
||
self._send_marked_read_event(user.id, chat.id, last_message.id)
|
||
|
||
def _send_marked_read_event(self, user_id, chat_id, message_id):
|
||
"""WebSocket событие для синхронизации и read receipts."""
|
||
channel_layer = get_channel_layer()
|
||
event_payload = {
|
||
'chat_id': chat_id,
|
||
'last_read_message_id': message_id,
|
||
'reader_user_id': user_id,
|
||
}
|
||
|
||
async_to_sync(channel_layer.group_send)(
|
||
f'user_{user_id}',
|
||
{
|
||
'type': 'chat_marked_read_sync',
|
||
**event_payload,
|
||
}
|
||
)
|
||
|
||
async_to_sync(channel_layer.group_send)(
|
||
f'chat_{chat_id}',
|
||
{
|
||
'type': 'chat_marked_read',
|
||
**event_payload,
|
||
}
|
||
)
|
||
|
||
@action(detail=True, methods=['get'])
|
||
def messages(self, request, pk=None):
|
||
"""Загрузка сообщений чата (пагинация по времени).
|
||
|
||
Query params:
|
||
- mark_read=false: не обновлять last_read_message при after_id/after_ts.
|
||
Нужен для тихой синхронизации клиента после разрыва WebSocket.
|
||
"""
|
||
chat = self.get_object()
|
||
|
||
# Проверка доступа
|
||
if not user_can_access_chat(chat, request.user):
|
||
return Response(
|
||
{'error': 'Access denied'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Параметры пагинации
|
||
before_ts = request.query_params.get('before')
|
||
before_id = request.query_params.get('before_id')
|
||
after_ts = request.query_params.get('after')
|
||
after_id = request.query_params.get('after_id')
|
||
try:
|
||
limit = min(int(request.query_params.get('limit', 50)), 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) 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% на новые
|
||
|
||
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) 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.info(
|
||
f"[messages_around] Searching by timestamp: {anchor_dt}, found: {anchor_msg}")
|
||
else:
|
||
# Это обычный message_id
|
||
try:
|
||
anchor_msg = queryset.get(pk=around_value)
|
||
logger.info(
|
||
f"[messages_around] Found message by ID: {around_value}")
|
||
except Message.DoesNotExist:
|
||
logger.warning(
|
||
f"[messages_around] Message with ID {around_value} not found")
|
||
except (ValueError, TypeError):
|
||
logger.warning(
|
||
f"[messages_around] Invalid around_id format: {around_id}")
|
||
|
||
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) 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) 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(
|
||
f"[mark_read] Manual mark by user {request.user.id} for chat {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 e:
|
||
logger.error(
|
||
f"[mark_read] Invalid message_id {message_id}: {e}")
|
||
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 e:
|
||
logger.error(f"[mark_read] Invalid timestamp: {e}")
|
||
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')
|
||
|
||
# Валидация чата
|
||
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,
|
||
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)
|
||
|
||
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': 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()
|
||
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.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(f"[update] Removed attachments: {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(
|
||
f"[update] Moved {updated_count} attachments to message {
|
||
instance.id}: {
|
||
list(ids_to_add)}")
|
||
|
||
instance.save()
|
||
|
||
# ВАЖНО: Очищаем кэш 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)
|
||
|
||
# WebSocket уведомление
|
||
channel_layer = get_channel_layer()
|
||
payload = serialize_message(instance)
|
||
|
||
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(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
|
||
)
|
||
|
||
instance.is_deleted = True
|
||
instance.deleted_at = timezone.now()
|
||
instance.deleted_by = request.user
|
||
instance.save()
|
||
|
||
# WebSocket уведомление
|
||
channel_layer = get_channel_layer()
|
||
async_to_sync(channel_layer.group_send)(
|
||
f'chat_{instance.chat_id}',
|
||
{
|
||
'type': 'chat.message_deleted',
|
||
'chat_id': instance.chat_id,
|
||
'message_id': instance.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()
|
||
logger.info(
|
||
f"[react] User {request.user.id} adding reaction to message {message.id}")
|
||
|
||
serializer = ReactionSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
emoji = serializer.validated_data['emoji']
|
||
logger.info(f"[react] Emoji: {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'])
|
||
logger.info(f"[react] Updated emoji from {old_emoji} to {emoji}")
|
||
else:
|
||
logger.info(f"[react] Created new reaction, id={reaction.id}")
|
||
|
||
# Пересчитываем reactions_summary
|
||
reactions_summary = {}
|
||
for r in message.reactions.select_related('user'):
|
||
if r.emoji not in reactions_summary:
|
||
reactions_summary[r.emoji] = {
|
||
'count': 0,
|
||
'users': [],
|
||
'user_names': []
|
||
}
|
||
reactions_summary[r.emoji]['count'] += 1
|
||
reactions_summary[r.emoji]['users'].append(r.user_id)
|
||
reactions_summary[r.emoji]['user_names'].append(
|
||
r.user.get_full_name())
|
||
|
||
logger.info(f"[react] reactions_summary: {reactions_summary}")
|
||
|
||
# WebSocket уведомление
|
||
channel_layer = get_channel_layer()
|
||
logger.info(f"[react] Sending to chat_{message.chat_id} group")
|
||
|
||
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("[react] WebSocket message sent successfully")
|
||
|
||
return Response({'ok': True, 'reactions_summary': reactions_summary})
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def unreact(self, request, pk=None):
|
||
"""Убрать реакцию"""
|
||
message = self.get_object()
|
||
logger.info(
|
||
f"[unreact] User {
|
||
request.user.id} removing reaction from message {
|
||
message.id}")
|
||
|
||
serializer = ReactionSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
emoji = serializer.validated_data['emoji']
|
||
logger.info(f"[unreact] Emoji: {emoji}")
|
||
|
||
deleted_count = MessageReaction.objects.filter(
|
||
message=message,
|
||
user=request.user,
|
||
emoji=emoji
|
||
).delete()[0]
|
||
|
||
logger.info(f"[unreact] Deleted count: {deleted_count}")
|
||
|
||
if deleted_count == 0:
|
||
return Response(
|
||
{'error': 'Reaction not found'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
|
||
# Пересчитываем reactions_summary
|
||
reactions_summary = {}
|
||
for r in message.reactions.select_related('user'):
|
||
if r.emoji not in reactions_summary:
|
||
reactions_summary[r.emoji] = {
|
||
'count': 0,
|
||
'users': [],
|
||
'user_names': []
|
||
}
|
||
reactions_summary[r.emoji]['count'] += 1
|
||
reactions_summary[r.emoji]['users'].append(r.user_id)
|
||
reactions_summary[r.emoji]['user_names'].append(
|
||
r.user.get_full_name())
|
||
|
||
logger.info(f"[unreact] reactions_summary: {reactions_summary}")
|
||
|
||
# WebSocket уведомление
|
||
channel_layer = get_channel_layer()
|
||
logger.info(f"[unreact] Sending to chat_{message.chat_id} group")
|
||
|
||
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("[unreact] WebSocket message sent successfully")
|
||
|
||
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']
|
||
|
||
# Удаляем только свои сообщения
|
||
deleted_count = Message.objects.filter(
|
||
id__in=message_ids,
|
||
author=request.user
|
||
).update(
|
||
is_deleted=True,
|
||
deleted_at=timezone.now(),
|
||
deleted_by=request.user
|
||
)
|
||
|
||
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.all()
|
||
serializer_class = PollSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def perform_create(self, serializer):
|
||
"""Автоматически устанавливаем автора голосования"""
|
||
serializer.save(author=self.request.user)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def vote(self, request, pk=None):
|
||
"""Проголосовать"""
|
||
poll = self.get_object()
|
||
|
||
if poll.is_closed:
|
||
return Response(
|
||
{'error': 'Poll is closed'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
option_ids = request.data.get('option_ids', [])
|
||
if not option_ids:
|
||
return Response(
|
||
{'error': 'No options selected'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# Проверка что опции существуют и принадлежат этому poll
|
||
try:
|
||
options = poll.options.filter(pk__in=option_ids)
|
||
if options.count() != len(option_ids):
|
||
return Response(
|
||
{'error': 'Invalid option IDs'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
except (ValueError, TypeError):
|
||
return Response(
|
||
{'error': 'Invalid option IDs format'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# Проверка множественного выбора
|
||
if not poll.is_multiple_choice and len(option_ids) > 1:
|
||
return Response(
|
||
{'error': 'Only one option allowed'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
with transaction.atomic():
|
||
# Удаляем ВСЕ старые голоса пользователя (и для single и для multiple
|
||
# choice)
|
||
PollVote.objects.filter(
|
||
poll=poll,
|
||
voter=request.user
|
||
).delete()
|
||
|
||
# Добавляем новые голоса
|
||
for option in options:
|
||
PollVote.objects.create(
|
||
poll=poll,
|
||
option=option,
|
||
voter=request.user
|
||
)
|
||
|
||
# Пересчитываем голоса
|
||
for option in poll.options.all():
|
||
option.vote_count = option.votes.count()
|
||
option.save()
|
||
|
||
poll.total_voters = PollVote.objects.filter(
|
||
poll=poll).values('voter').distinct().count()
|
||
poll.save()
|
||
|
||
# WebSocket уведомление
|
||
channel_layer = get_channel_layer()
|
||
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,
|
||
'total_voters': poll.total_voters
|
||
}
|
||
}
|
||
)
|
||
|
||
# Возвращаем полные результаты
|
||
results_serializer = PollSerializer(poll, context={'request': request})
|
||
return Response({'ok': True, 'results': results_serializer.data})
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def close(self, request, pk=None):
|
||
"""Закрыть голосование"""
|
||
poll = self.get_object()
|
||
|
||
# Проверка прав (только автор сообщения)
|
||
if poll.message.author != request.user:
|
||
return Response(
|
||
{'error': 'Only poll author can close it'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Используем метод модели для установки is_closed и closed_at
|
||
poll.close()
|
||
|
||
return Response(PollSerializer(poll, context={'request': request}).data)
|
||
|
||
@action(detail=True, methods=['get', 'post'])
|
||
def results(self, request, pk=None):
|
||
"""Получить результаты голосования"""
|
||
poll = self.get_object()
|
||
serializer = PollSerializer(poll, context={'request': request})
|
||
return Response(serializer.data)
|