253 lines
11 KiB
Python
253 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 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 .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(members=user)
|
|
|
|
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
|
|
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,
|
|
)
|