From 28614ad870d6023cb4d52defac6e88a7a1d0396e Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sat, 17 Oct 2026 15:13:15 +0330 Subject: [PATCH] feat(support): add admin panel REST endpoints for support chat rooms and messages - Add AdminSupportRoomSerializer, AdminSupportMessageSerializer, and user serialization utilities - Implement AdminSupportRoomListView for listing and searching rooms with total unread counters - Implement AdminSupportRoomByUserView for starting or opening rooms by user_id - Implement AdminSupportRoomMessagesView for message retrieval and sending specialist responses - Implement AdminSupportMessageDetailView for message editing and deletion - Enforce staff/admin authorization with IsSupportAdminPanelUser permission --- apps/support/serializers.py | 170 +++++++++++++++++++ apps/support/urls.py | 16 +- apps/support/views.py | 314 +++++++++++++++++++++++++++++++++++- 3 files changed, 498 insertions(+), 2 deletions(-) diff --git a/apps/support/serializers.py b/apps/support/serializers.py index 70e4ae4..33a1af0 100644 --- a/apps/support/serializers.py +++ b/apps/support/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers from apps.support.models import SupportRoom, SupportMessage +from utils import absolute_https_url class SupportMessageSerializer(serializers.ModelSerializer): @@ -55,3 +56,172 @@ class SupportRoomSerializer(serializers.ModelSerializer): 'created_at', ) read_only_fields = fields + + +def serialize_admin_chat_user(user, request=None) -> dict: + if not user: + return { + 'id': 0, + 'email': '', + 'fullname': 'Unknown User', + 'avatar': '', + 'phone_number': '', + 'language': '', + 'gender': '', + 'user_type': 'client', + 'is_guest': True, + } + + is_guest = getattr(user, 'is_guest', False) or not bool(user.email) + fullname = (user.fullname or '').strip() + if not fullname: + if user.email: + fullname = user.email.split('@')[0] + else: + fullname = f"Guest #{user.id}" + + avatar_url = '' + if getattr(user, 'avatar', None): + try: + avatar_url = ( + absolute_https_url(user.avatar.url, request) + if request + else user.avatar.url + ) or '' + except Exception: + avatar_url = '' + + return { + 'id': user.id, + 'email': user.email or '', + 'fullname': fullname, + 'avatar': avatar_url, + 'phone_number': str(user.phone_number) if getattr(user, 'phone_number', None) else '', + 'language': str(user.language) if getattr(user, 'language', None) else '', + 'gender': user.gender or '', + 'user_type': 'guest' if is_guest else (user.user_type or 'client'), + 'is_guest': is_guest, + } + + +def detect_content_type(content: str) -> str: + if not content: + return 'text' + lower = content.lower().strip() + if lower.startswith(('http://', 'https://', '/media/')): + if any(lower.endswith(ext) for ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg')): + return 'image' + if any(lower.endswith(ext) for ext in ('.mp3', '.wav', '.ogg', '.webm', '.m4a')): + return 'audio' + if any(lower.endswith(ext) for ext in ('.mp4', '.mov', '.mkv')): + return 'video' + return 'text' + + +class AdminSupportMessageSerializer(serializers.ModelSerializer): + room_id = serializers.IntegerField(read_only=True) + sender = serializers.SerializerMethodField() + content_type = serializers.SerializerMethodField() + sent_at = serializers.SerializerMethodField() + updated_at = serializers.SerializerMethodField() + is_deleted = serializers.SerializerMethodField() + + class Meta: + model = SupportMessage + fields = ( + 'id', + 'room_id', + 'sender_type', + 'sender', + 'content', + 'content_type', + 'sent_at', + 'updated_at', + 'is_read', + 'is_deleted', + ) + read_only_fields = fields + + def get_sender(self, obj: SupportMessage) -> dict: + request = self.context.get('request') + if obj.sender_type == SupportMessage.SenderType.SUPPORT: + sender_user = obj.sender or (request.user if request and request.user.is_authenticated else None) + data = serialize_admin_chat_user(sender_user, request=request) + if not sender_user: + data['fullname'] = 'Support Specialist' + return data + sender_user = obj.sender or (obj.room.user if obj.room_id else None) + return serialize_admin_chat_user(sender_user, request=request) + + def get_content_type(self, obj: SupportMessage) -> str: + return detect_content_type(obj.content) + + def get_sent_at(self, obj: SupportMessage) -> str: + return obj.created_at.isoformat() if obj.created_at else '' + + def get_updated_at(self, obj: SupportMessage) -> str: + return obj.created_at.isoformat() if obj.created_at else '' + + def get_is_deleted(self, obj: SupportMessage) -> bool: + return False + + +class AdminSupportRoomSerializer(serializers.ModelSerializer): + name = serializers.SerializerMethodField() + room_type = serializers.SerializerMethodField() + initiator = serializers.SerializerMethodField() + recipient = serializers.SerializerMethodField() + participants = serializers.SerializerMethodField() + unread_messages_count = serializers.IntegerField(source='unread_by_admin', read_only=True) + last_message = serializers.SerializerMethodField() + + class Meta: + model = SupportRoom + fields = ( + 'id', + 'name', + 'room_type', + 'status', + 'initiator', + 'recipient', + 'participants', + 'unread_messages_count', + 'last_message', + 'created_at', + 'updated_at', + ) + read_only_fields = fields + + def get_name(self, obj: SupportRoom) -> str: + request = self.context.get('request') + user_data = serialize_admin_chat_user(obj.user, request=request) + return user_data['fullname'] + + def get_room_type(self, obj: SupportRoom) -> str: + return 'private' + + def get_initiator(self, obj: SupportRoom) -> dict: + request = self.context.get('request') + return serialize_admin_chat_user(obj.user, request=request) + + def get_recipient(self, obj: SupportRoom) -> dict: + request = self.context.get('request') + specialist = obj.assigned_to or (request.user if request and request.user.is_authenticated else None) + return serialize_admin_chat_user(specialist, request=request) + + def get_participants(self, obj: SupportRoom) -> list: + return [self.get_initiator(obj), self.get_recipient(obj)] + + def get_last_message(self, obj: SupportRoom): + last_msg = getattr(obj, '_prefetched_last_message', None) + if last_msg is None: + last_msg = obj.messages.order_by('-id').first() + if not last_msg: + return None + return { + 'id': last_msg.id, + 'content': last_msg.content, + 'content_type': detect_content_type(last_msg.content), + 'sent_at': last_msg.created_at.isoformat() if last_msg.created_at else '', + 'sender_type': last_msg.sender_type, + } diff --git a/apps/support/urls.py b/apps/support/urls.py index e51e6d4..621c4df 100644 --- a/apps/support/urls.py +++ b/apps/support/urls.py @@ -1,9 +1,23 @@ from django.urls import path -from apps.support.views import SupportChatMessagesView, SupportUnreadCountView +from apps.support.views import ( + AdminSupportMessageDetailView, + AdminSupportRoomByUserView, + AdminSupportRoomListView, + AdminSupportRoomMessagesView, + SupportChatMessagesView, + SupportUnreadCountView, +) app_name = 'support' urlpatterns = [ + # Client / Guest endpoints path('messages/', SupportChatMessagesView.as_view(), name='support-messages'), path('unread/', SupportUnreadCountView.as_view(), name='support-unread'), + + # Admin Panel (`dovodi-panel`) endpoints + path('admin/rooms/', AdminSupportRoomListView.as_view(), name='support-admin-rooms'), + path('admin/rooms/by-user/', AdminSupportRoomByUserView.as_view(), name='support-admin-room-by-user'), + path('admin/rooms//messages/', AdminSupportRoomMessagesView.as_view(), name='support-admin-room-messages'), + path('admin/messages//', AdminSupportMessageDetailView.as_view(), name='support-admin-message-detail'), ] diff --git a/apps/support/views.py b/apps/support/views.py index f86b55d..eb35f2d 100644 --- a/apps/support/views.py +++ b/apps/support/views.py @@ -1,18 +1,49 @@ +import math +import re +from django.contrib.auth import get_user_model from django.db import transaction +from django.db.models import Q, Sum +from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from apps.support.models import SupportMessage, SupportRoom from apps.support.serializers import ( + AdminSupportMessageSerializer, + AdminSupportRoomSerializer, SendSupportMessageSerializer, SupportMessageSerializer, SupportRoomSerializer, + serialize_admin_chat_user, ) +User = get_user_model() + + +class IsSupportAdminPanelUser(BasePermission): + """ + Allows access to users who can log into the admin panel (super_admin, admin, professor, staff). + """ + + def has_permission(self, request, view): + user = request.user + if not user or not user.is_authenticated: + return False + if getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False): + return True + if hasattr(user, 'can_access_admin_panel') and user.can_access_admin_panel(): + return True + return getattr(user, 'user_type', None) in ('super_admin', 'admin', 'professor', 'consultant') + + +def get_total_admin_unread_count() -> int: + agg = SupportRoom.objects.aggregate(total=Sum('unread_by_admin')) + return int(agg.get('total') or 0) + def resolve_user_support_room(request, create_if_missing: bool = False): """ @@ -144,3 +175,284 @@ class SupportUnreadCountView(APIView): }, status=status.HTTP_200_OK, ) + + +class AdminSupportRoomListView(APIView): + """ + GET /api/support/admin/rooms/?page=1&per_page=20&search=... + Lists all SupportRooms for the admin dashboard (`dovodi-panel`). + """ + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated, IsSupportAdminPanelUser] + + def get(self, request, *args, **kwargs): + try: + page = max(1, int(request.query_params.get('page', 1))) + except (TypeError, ValueError): + page = 1 + + try: + per_page = max(1, min(100, int(request.query_params.get('per_page', 20)))) + except (TypeError, ValueError): + per_page = 20 + + search = (request.query_params.get('search') or '').strip() + + qs = SupportRoom.objects.select_related('user', 'assigned_to').order_by( + '-last_message_at', + '-updated_at', + '-id', + ) + + if search: + q_filter = ( + Q(user__fullname__icontains=search) + | Q(user__email__icontains=search) + | Q(user__phone_number__icontains=search) + | Q(messages__content__icontains=search) + ) + digits_match = re.search(r'(\d+)', search) + if digits_match: + num_val = int(digits_match.group(1)) + q_filter |= Q(id=num_val) | Q(user__id=num_val) + elif search.lower() == 'guest': + q_filter |= Q(user__email__isnull=True) + + qs = qs.filter(q_filter).distinct() + + total_rooms = qs.count() + total_pages = max(1, math.ceil(total_rooms / per_page)) + offset = (page - 1) * per_page + rooms_page = list(qs[offset:offset + per_page]) + + serialized_rooms = AdminSupportRoomSerializer( + rooms_page, + many=True, + context={'request': request}, + ).data + + return Response( + { + 'status': 'success', + 'current_user': serialize_admin_chat_user(request.user, request=request), + 'room_messages': serialized_rooms, + 'total_pages': total_pages, + 'current_page': page, + 'per_page': per_page, + 'total_rooms': total_rooms, + 'total_unread_count': get_total_admin_unread_count(), + }, + status=status.HTTP_200_OK, + ) + + +class AdminSupportRoomByUserView(APIView): + """ + POST /api/support/admin/rooms/by-user/ + Body: { "user_id": } + Gets or creates a SupportRoom for a specific user when an admin starts a new chat from the panel. + """ + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated, IsSupportAdminPanelUser] + + @transaction.atomic + def post(self, request, *args, **kwargs): + user_id = request.data.get('user_id') or request.data.get('recipient_id') + target_user = get_object_or_404(User, pk=user_id) + room, _ = SupportRoom.objects.get_or_create( + user=target_user, + defaults={'assigned_to': request.user}, + ) + + # Mark any unread messages from this user as read + if room.unread_by_admin > 0: + SupportMessage.objects.filter( + room=room, + sender_type=SupportMessage.SenderType.USER, + is_read=False, + ).update(is_read=True) + room.unread_by_admin = 0 + room.save(update_fields=['unread_by_admin', 'updated_at']) + + messages_qs = room.messages.select_related('sender').order_by('id') + messages_list = list(messages_qs) + + return Response( + { + 'status': 'success', + 'room': AdminSupportRoomSerializer(room, context={'request': request}).data, + 'messages': AdminSupportMessageSerializer( + messages_list, + many=True, + context={'request': request}, + ).data, + 'current_user': serialize_admin_chat_user(request.user, request=request), + 'total_pages': 1, + 'current_page': 1, + 'per_page': max(20, len(messages_list)), + 'total_messages': len(messages_list), + 'total_unread_count': get_total_admin_unread_count(), + }, + status=status.HTTP_200_OK, + ) + + +class AdminSupportRoomMessagesView(APIView): + """ + GET /api/support/admin/rooms//messages/?after_id= + Returns messages for a SupportRoom and marks unread user messages as read. + POST /api/support/admin/rooms//messages/ + Sends a reply from the admin/specialist (`sender_type='support'`). + """ + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated, IsSupportAdminPanelUser] + + def get(self, request, room_id: int, *args, **kwargs): + room = get_object_or_404( + SupportRoom.objects.select_related('user', 'assigned_to'), + pk=room_id, + ) + + after_id_param = request.query_params.get('after_id') + messages_qs = room.messages.select_related('sender').order_by('id') + + if after_id_param is not None: + try: + after_id = int(after_id_param) + if after_id > 0: + messages_qs = messages_qs.filter(id__gt=after_id) + except (TypeError, ValueError): + pass + + messages_list = list(messages_qs) + + # Mark user messages as read when admin views the room + if room.unread_by_admin > 0 or any( + m.sender_type == SupportMessage.SenderType.USER and not m.is_read + for m in messages_list + ): + SupportMessage.objects.filter( + room=room, + sender_type=SupportMessage.SenderType.USER, + is_read=False, + ).update(is_read=True) + if room.unread_by_admin != 0: + room.unread_by_admin = 0 + room.save(update_fields=['unread_by_admin', 'updated_at']) + + total_messages = room.messages.count() + + return Response( + { + 'status': 'success', + 'room': AdminSupportRoomSerializer(room, context={'request': request}).data, + 'messages': AdminSupportMessageSerializer( + messages_list, + many=True, + context={'request': request}, + ).data, + 'current_user': serialize_admin_chat_user(request.user, request=request), + 'total_pages': 1, + 'current_page': 1, + 'per_page': max(50, total_messages), + 'total_messages': total_messages, + 'total_unread_count': get_total_admin_unread_count(), + }, + status=status.HTTP_200_OK, + ) + + @transaction.atomic + def post(self, request, room_id: int, *args, **kwargs): + room = get_object_or_404( + SupportRoom.objects.select_related('user', 'assigned_to'), + pk=room_id, + ) + serializer = SendSupportMessageSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if not room.assigned_to_id: + room.assigned_to = request.user + room.save(update_fields=['assigned_to', 'updated_at']) + + message = SupportMessage.objects.create( + room=room, + sender_type=SupportMessage.SenderType.SUPPORT, + sender=request.user, + content=serializer.validated_data['content'], + ) + room.refresh_from_db() + + return Response( + { + 'status': 'success', + 'room': AdminSupportRoomSerializer(room, context={'request': request}).data, + 'message': AdminSupportMessageSerializer( + message, + context={'request': request}, + ).data, + 'current_user': serialize_admin_chat_user(request.user, request=request), + 'total_unread_count': get_total_admin_unread_count(), + }, + status=status.HTTP_201_CREATED, + ) + + +class AdminSupportMessageDetailView(APIView): + """ + PATCH /api/support/admin/messages// + Updates the content of a support message. + DELETE /api/support/admin/messages// + Deletes a support message. + """ + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated, IsSupportAdminPanelUser] + + @transaction.atomic + def patch(self, request, message_id: int, *args, **kwargs): + message = get_object_or_404( + SupportMessage.objects.select_related('room', 'sender'), + pk=message_id, + ) + serializer = SendSupportMessageSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + message.content = serializer.validated_data['content'] + message.save(update_fields=['content']) + + return Response( + { + 'status': 'success', + 'room_id': message.room_id, + 'message': AdminSupportMessageSerializer( + message, + context={'request': request}, + ).data, + 'total_unread_count': get_total_admin_unread_count(), + }, + status=status.HTTP_200_OK, + ) + + @transaction.atomic + def delete(self, request, message_id: int, *args, **kwargs): + message = get_object_or_404( + SupportMessage.objects.select_related('room'), + pk=message_id, + ) + room = message.room + deleted_id = message.id + message.delete() + + last_msg = room.messages.order_by('-id').first() + room.last_message_at = last_msg.created_at if last_msg else None + room.save(update_fields=['last_message_at', 'updated_at']) + + return Response( + { + 'status': 'success', + 'room_id': room.id, + 'deleted_message_id': deleted_id, + 'room': AdminSupportRoomSerializer(room, context={'request': request}).data, + 'total_unread_count': get_total_admin_unread_count(), + }, + status=status.HTTP_200_OK, + )