Files
vontor-cz/backend/social/chat/views.py
2026-05-28 17:23:04 +02:00

256 lines
11 KiB
Python

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.models import Q
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, extend_schema_view
from vontor_cz.pagination import CreatedCursorPagination
from .models import Chat, Message, MessageFile
from .permissions import CanDeleteMessage, CanManageChat, IsChatMember, IsMessageSenderOnly
from .serializers import ChatMemberSerializer, ChatSerializer, MessageSendSerializer, MessageSerializer
def _broadcast(chat_id, payload):
"""Push a payload to the channel group for a chat from sync code."""
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(f"chat_{chat_id}", payload)
# ---------------------------------------------------------------------------
# Chat ViewSet
# ---------------------------------------------------------------------------
@extend_schema_view(
list=extend_schema(tags=["chat"], summary="List chats", description="Returns all chats the user is a member of. Superusers see all."),
retrieve=extend_schema(tags=["chat"], summary="Retrieve a chat"),
create=extend_schema(tags=["chat"], summary="Create a chat", description="Owner is set to the requesting user automatically."),
update=extend_schema(tags=["chat"], summary="Replace a chat", description="Owner, moderator, or superuser only."),
partial_update=extend_schema(tags=["chat"], summary="Update a chat", description="Owner, moderator, or superuser only."),
destroy=extend_schema(tags=["chat"], summary="Delete a chat", description="Soft-deletes. Owner or superuser only."),
)
class ChatViewSet(viewsets.ModelViewSet):
serializer_class = ChatSerializer
filterset_fields = ['chat_type', 'hub']
search_fields = ['name']
ordering_fields = ['created_at', 'name']
ordering = ['-created_at']
def get_permissions(self):
if self.action in ('update', 'partial_update', 'destroy',
'add_member', 'remove_member', 'add_moderator', 'remove_moderator'):
return [CanManageChat()]
return [IsChatMember()]
def get_queryset(self):
user = self.request.user
if user.is_superuser:
return Chat.objects.all()
return Chat.objects.filter(Q(members=user) | Q(owner=user)).distinct()
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
# ------------------------------------------------------------------
# Member management
# ------------------------------------------------------------------
@extend_schema(
tags=["chat"],
summary="Add a member",
description="Owner, moderator, or superuser only.",
request=ChatMemberSerializer,
responses={200: ChatSerializer},
)
@action(detail=True, methods=['post'], url_path='members/add')
def add_member(self, request, pk=None):
chat = self.get_object()
ser = ChatMemberSerializer(data=request.data)
ser.is_valid(raise_exception=True)
User = get_user_model()
try:
user = User.objects.get(pk=ser.validated_data['user_id'])
except User.DoesNotExist:
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
chat.members.add(user)
return Response(ChatSerializer(chat, context={'request': request}).data)
@extend_schema(
tags=["chat"],
summary="Remove a member",
description="Owner, moderator, or superuser only.",
request=ChatMemberSerializer,
responses={204: None},
)
@action(detail=True, methods=['post'], url_path='members/remove')
def remove_member(self, request, pk=None):
chat = self.get_object()
ser = ChatMemberSerializer(data=request.data)
ser.is_valid(raise_exception=True)
User = get_user_model()
try:
user = User.objects.get(pk=ser.validated_data['user_id'])
except User.DoesNotExist:
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
chat.members.remove(user)
return Response(status=status.HTTP_204_NO_CONTENT)
@extend_schema(
tags=["chat"],
summary="Add a moderator",
description="Owner or superuser only.",
request=ChatMemberSerializer,
responses={200: ChatSerializer},
)
@action(detail=True, methods=['post'], url_path='moderators/add')
def add_moderator(self, request, pk=None):
chat = self.get_object()
if not (chat.owner == request.user or request.user.is_superuser):
raise PermissionDenied('Only the chat owner or superuser can add moderators.')
ser = ChatMemberSerializer(data=request.data)
ser.is_valid(raise_exception=True)
User = get_user_model()
try:
user = User.objects.get(pk=ser.validated_data['user_id'])
except User.DoesNotExist:
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
chat.moderators.add(user)
return Response(ChatSerializer(chat, context={'request': request}).data)
@extend_schema(
tags=["chat"],
summary="Remove a moderator",
description="Owner or superuser only.",
request=ChatMemberSerializer,
responses={204: None},
)
@action(detail=True, methods=['post'], url_path='moderators/remove')
def remove_moderator(self, request, pk=None):
chat = self.get_object()
if not (chat.owner == request.user or request.user.is_superuser):
raise PermissionDenied('Only the chat owner or superuser can remove moderators.')
ser = ChatMemberSerializer(data=request.data)
ser.is_valid(raise_exception=True)
User = get_user_model()
try:
user = User.objects.get(pk=ser.validated_data['user_id'])
except User.DoesNotExist:
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
chat.moderators.remove(user)
return Response(status=status.HTTP_204_NO_CONTENT)
# ---------------------------------------------------------------------------
# Message ViewSet (read + edit + delete only — creation is via WebSocket)
# ---------------------------------------------------------------------------
@extend_schema_view(
list=extend_schema(tags=["chat"], summary="List messages", description="Filter by `?chat=<id>`. Chat members only."),
retrieve=extend_schema(tags=["chat"], summary="Retrieve a message"),
partial_update=extend_schema(tags=["chat"], summary="Edit a message", description="Sender only. Broadcasts `edit.message` to the chat channel group in real time."),
update=extend_schema(tags=["chat"], summary="Replace a message", description="Sender only."),
destroy=extend_schema(tags=["chat"], summary="Delete a message", description="Sender, chat owner, moderator, or superuser. Broadcasts `delete.message` to the chat channel group in real time."),
)
class MessageViewSet(viewsets.ModelViewSet):
serializer_class = MessageSerializer
pagination_class = CreatedCursorPagination
filterset_fields = ['chat', 'sender', 'reply_to']
search_fields = ['content']
ordering_fields = ['created_at']
ordering = ['-created_at']
# Standard create is disabled — use POST /messages/send which handles files + WS broadcast
http_method_names = ['get', 'patch', 'put', 'delete', 'post', 'head', 'options']
def get_permissions(self):
if self.action == 'destroy':
return [CanDeleteMessage()]
if self.action in ('update', 'partial_update'):
return [IsMessageSenderOnly()]
if self.action == 'send':
return [IsAuthenticated()]
# list, retrieve — any authenticated user (membership enforced by get_queryset)
return [IsAuthenticated()]
def get_queryset(self):
user = self.request.user
qs = Message.objects.select_related('sender', 'chat').prefetch_related('media_files', 'reactions')
if user.is_superuser:
return qs
# Only messages from chats the user is a member of
return qs.filter(chat__members=user)
def perform_update(self, serializer):
message = serializer.instance
new_content = serializer.validated_data.get('content', message.content)
changed = message.edit_content(new_content)
if changed:
_broadcast(message.chat_id, {
'type': 'edit.message',
'message_id': message.id,
'content': message.content,
'is_edited': True,
})
def perform_destroy(self, instance):
chat_id = instance.chat_id
message_id = instance.id
instance.delete()
_broadcast(chat_id, {
'type': 'delete.message',
'message_id': message_id,
})
@extend_schema(
tags=["chat"],
summary="Send a message",
description=(
"Creates a message with optional file attachments and broadcasts it to all "
"connected WebSocket clients in the chat. Use this for all messages that include "
"files, and optionally for text-only messages too. Chat members only."
),
request=MessageSendSerializer,
responses={201: MessageSerializer},
)
@action(detail=False, methods=['post'], url_path='send')
def send(self, request):
ser = MessageSendSerializer(data=request.data)
ser.is_valid(raise_exception=True)
chat = ser.validated_data['chat']
if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists():
raise PermissionDenied('You are not a member of this chat.')
message = Message.objects.create(
chat=chat,
sender=request.user,
content=ser.validated_data.get('content', ''),
reply_to=ser.validated_data.get('reply_to'),
)
for f in request.FILES.getlist('files'):
ct = getattr(f, 'content_type', '')
if ct.startswith('image/'):
mt = 'IMAGE'
elif ct.startswith('video/'):
mt = 'VIDEO'
else:
mt = 'FILE'
MessageFile.objects.create(message=message, file=f, media_type=mt)
_broadcast(chat.id, {
'type': 'chat.message',
'message_id': message.id,
'message': message.content,
'sender': request.user.username,
'has_files': message.media_files.exists(),
})
return Response(
MessageSerializer(message, context={'request': request}).data,
status=status.HTTP_201_CREATED,
)