import logging from django.conf import settings from django.contrib.auth import get_user_model from django.db.models import Q from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse from apps.chat.models.chat import ChatRoom, ChatMessage from apps.chat.permissions import IsRoomParticipant from apps.chat.services.centrifugo import generate_centrifugo_token, publish_to_centrifugo from apps.chat.serializers import ( CentrifugoTokenResponseSerializer, ChatMessageSerializer, ChatMessageCreateSerializer, ChatRoomListSerializer, ChatRoomDetailSerializer, ChatRoomCreateSerializer, ) from apps.profiles.models.institution import Institution from utils.pagination import StandardResultsSetPagination logger = logging.getLogger(__name__) User = get_user_model() class CentrifugoTokenView(GenericAPIView): permission_classes = [IsAuthenticated] serializer_class = CentrifugoTokenResponseSerializer @extend_schema( summary="Obtain Centrifugo real-time WebSocket connection token", description="Generates an HMAC-SHA256 connection JWT for client WebSocket authentication with Centrifugo v5.", responses={200: CentrifugoTokenResponseSerializer}, tags=["Real-Time Messaging & Chat"], ) def get(self, request, *args, **kwargs): token = generate_centrifugo_token(request.user) ws_url = getattr(settings, 'CENTRIFUGO_WS_URL', 'ws://localhost:8001/connection/websocket') return Response({ 'token': token, 'ws_url': ws_url, 'user_id': request.user.id, 'expires_in': 86400 * 7, }, status=status.HTTP_200_OK) class ChatRoomListCreateView(GenericAPIView): permission_classes = [IsAuthenticated] serializer_class = ChatRoomListSerializer pagination_class = StandardResultsSetPagination queryset = ChatRoom.objects.all() def get_queryset(self): if getattr(self, 'swagger_fake_view', False): return ChatRoom.objects.none() user = self.request.user user_inst_ids = user.institution_memberships.values_list('institution_id', flat=True) return ChatRoom.objects.filter( Q(participant_users=user) | Q(participant_institutions__id__in=user_inst_ids) ).distinct().prefetch_related('participant_users', 'participant_institutions', 'messages').order_by('-updated_at') @extend_schema( summary="List active conversations & direct chats", description="Returns all active direct and group conversations for the authenticated user with unread counts.", responses={200: ChatRoomListSerializer(many=True)}, tags=["Real-Time Messaging & Chat"], ) def get(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True, context={'request': request}) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) @extend_schema( summary="Start conversation with user or institution", description="Creates a new direct chat or multi-institution group room. Reuses existing direct chat if already active.", request=ChatRoomCreateSerializer, responses={ 201: ChatRoomDetailSerializer, 200: ChatRoomDetailSerializer, 400: OpenApiResponse(description="Validation error"), }, tags=["Real-Time Messaging & Chat"], ) def post(self, request, *args, **kwargs): serializer = ChatRoomCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data room_type = data.get('room_type', ChatRoom.RoomType.DIRECT) title = data.get('title', '') target_user_id = data.get('target_user_id') target_institution_id = data.get('target_institution_id') user_ids = set(data.get('participant_user_ids', [])) inst_ids = set(data.get('participant_institution_ids', [])) # Always include the requesting user user_ids.add(request.user.id) if target_user_id: user_ids.add(target_user_id) if target_institution_id: inst_ids.add(target_institution_id) # Check if direct conversation already exists between the two entities if room_type == ChatRoom.RoomType.DIRECT and target_user_id: existing_room = ChatRoom.objects.filter( room_type=ChatRoom.RoomType.DIRECT, participant_users=request.user ).filter(participant_users__id=target_user_id).first() if existing_room: # If initial message provided, dispatch it if data.get('initial_message'): msg = ChatMessage.objects.create( room=existing_room, sender=request.user, content=data['initial_message'] ) publish_to_centrifugo( existing_room.centrifugo_channel, {'type': 'new_message', 'message': ChatMessageSerializer(msg, context={'request': request}).data} ) return Response(ChatRoomDetailSerializer(existing_room, context={'request': request}).data, status=status.HTTP_200_OK) # Create new room room = ChatRoom.objects.create( room_type=room_type, title=title, created_by=request.user ) if user_ids: room.participant_users.set(User.objects.filter(id__in=user_ids)) if inst_ids: room.participant_institutions.set(Institution.objects.filter(id__in=inst_ids)) # Send initial message if provided if data.get('initial_message'): msg = ChatMessage.objects.create( room=room, sender=request.user, content=data['initial_message'] ) publish_to_centrifugo( room.centrifugo_channel, {'type': 'new_message', 'message': ChatMessageSerializer(msg, context={'request': request}).data} ) return Response(ChatRoomDetailSerializer(room, context={'request': request}).data, status=status.HTTP_201_CREATED) class ChatRoomDetailView(GenericAPIView): permission_classes = [IsAuthenticated, IsRoomParticipant] serializer_class = ChatRoomDetailSerializer queryset = ChatRoom.objects.all() def get_object(self): room = get_object_or_404(ChatRoom, id=self.kwargs['pk']) self.check_object_permissions(self.request, room) return room @extend_schema( summary="Get chat room details & metadata", description="Retrieves metadata, participants, and channel name for a conversation.", responses={ 200: ChatRoomDetailSerializer, 403: OpenApiResponse(description="Not a participant in this conversation"), 404: OpenApiResponse(description="Room not found"), }, tags=["Real-Time Messaging & Chat"], ) def get(self, request, pk, *args, **kwargs): room = self.get_object() serializer = ChatRoomDetailSerializer(room, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) class ChatMessageListCreateView(GenericAPIView): permission_classes = [IsAuthenticated, IsRoomParticipant] serializer_class = ChatMessageSerializer parser_classes = [MultiPartParser, FormParser, JSONParser] pagination_class = StandardResultsSetPagination queryset = ChatMessage.objects.all() def get_room(self): room = get_object_or_404(ChatRoom, id=self.kwargs['pk']) self.check_object_permissions(self.request, room) return room @extend_schema( summary="Get message history for chat room", description="Returns paginated message history for a conversation and marks messages as read.", responses={200: ChatMessageSerializer(many=True)}, tags=["Real-Time Messaging & Chat"], ) def get(self, request, pk, *args, **kwargs): room = self.get_room() messages_qs = room.messages.all().select_related('sender', 'sender_institution').order_by('-created_at') # Automatically mark retrieved messages as read for this user unread_messages = room.messages.exclude(read_by=request.user).exclude(sender=request.user) for unread_msg in unread_messages: unread_msg.read_by.add(request.user) page = self.paginate_queryset(messages_qs) if page is not None: serializer = ChatMessageSerializer(page, many=True, context={'request': request}) return self.get_paginated_response(serializer.data) serializer = ChatMessageSerializer(messages_qs, many=True, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) @extend_schema( summary="Send message in conversation", description="Persists message to database and broadcasts real-time WebSocket event via Centrifugo.", request=ChatMessageCreateSerializer, responses={ 201: ChatMessageSerializer, 400: OpenApiResponse(description="Validation error"), 403: OpenApiResponse(description="Permission denied"), }, tags=["Real-Time Messaging & Chat"], ) def post(self, request, pk, *args, **kwargs): room = self.get_room() serializer = ChatMessageCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) content = serializer.validated_data.get('content', '') attachment = serializer.validated_data.get('attachment') sender_inst_id = serializer.validated_data.get('sender_institution_id') sender_institution = None if sender_inst_id: sender_institution = Institution.objects.filter(id=sender_inst_id).first() message = ChatMessage.objects.create( room=room, sender=request.user, sender_institution=sender_institution, content=content, attachment=attachment ) # Broadcast via Centrifugo HTTP API msg_data = ChatMessageSerializer(message, context={'request': request}).data publish_to_centrifugo( room.centrifugo_channel, { 'type': 'new_message', 'room_id': room.id, 'message': msg_data } ) return Response(msg_data, status=status.HTTP_201_CREATED) class ChatRoomMarkReadView(GenericAPIView): permission_classes = [IsAuthenticated, IsRoomParticipant] serializer_class = ChatMessageSerializer queryset = ChatRoom.objects.all() @extend_schema( summary="Mark all conversation messages as read", description="Updates read receipts for all messages in the room and emits read event.", request=None, responses={200: OpenApiResponse(description="Messages marked as read")}, tags=["Real-Time Messaging & Chat"], ) def post(self, request, pk, *args, **kwargs): room = get_object_or_404(ChatRoom, id=pk) self.check_object_permissions(request, room) unread_messages = room.messages.exclude(read_by=request.user) for msg in unread_messages: msg.read_by.add(request.user) # Broadcast read receipt via Centrifugo publish_to_centrifugo( room.centrifugo_channel, { 'type': 'messages_read', 'room_id': room.id, 'user_id': request.user.id } ) return Response({'status': 'read', 'message': _("All messages marked as read.")}, status=status.HTTP_200_OK)