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=`. 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, )