Browse Source

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
master
Mohsen Taba 2 weeks ago
parent
commit
28614ad870
  1. 170
      apps/support/serializers.py
  2. 16
      apps/support/urls.py
  3. 314
      apps/support/views.py

170
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,
}

16
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/<int:room_id>/messages/', AdminSupportRoomMessagesView.as_view(), name='support-admin-room-messages'),
path('admin/messages/<int:message_id>/', AdminSupportMessageDetailView.as_view(), name='support-admin-message-detail'),
]

314
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": <int> }
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/<int:room_id>/messages/?after_id=<id>
Returns messages for a SupportRoom and marks unread user messages as read.
POST /api/support/admin/rooms/<int:room_id>/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/<int:message_id>/
Updates the content of a support message.
DELETE /api/support/admin/messages/<int:message_id>/
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,
)
Loading…
Cancel
Save