Browse Source
feat(support): add support chat system with room models, APIs, and admin dashboard
feat(support): add support chat system with room models, APIs, and admin dashboard
- Add SupportRoom and SupportMessage models with automated status and unread counters - Implement API endpoints for message history, message sending, and unread checking - Support guest session tracking and seamless room migration via X-Guest-Token - Register SupportRoom and inline messages in Unfold admin dashboard - Add initial migration 0001_initial and register app in settings and URLsmaster
11 changed files with 483 additions and 0 deletions
-
2apps/support/__init__.py
-
67apps/support/admin.py
-
8apps/support/apps.py
-
51apps/support/migrations/0001_initial.py
-
1apps/support/migrations/__init__.py
-
140apps/support/models.py
-
57apps/support/serializers.py
-
9apps/support/urls.py
-
146apps/support/views.py
-
1config/settings/base.py
-
1config/urls.py
@ -0,0 +1,2 @@ |
|||||
|
# Support chat application |
||||
|
default_app_config = 'apps.support.apps.SupportConfig' |
||||
@ -0,0 +1,67 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.html import format_html |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from unfold.admin import ModelAdmin, TabularInline |
||||
|
from unfold.decorators import display |
||||
|
|
||||
|
from apps.support.models import SupportMessage, SupportRoom |
||||
|
from utils.admin import dovoodi_admin_site, project_admin_site |
||||
|
|
||||
|
|
||||
|
class SupportMessageInline(TabularInline): |
||||
|
model = SupportMessage |
||||
|
extra = 1 |
||||
|
fields = ('sender_type', 'sender', 'content', 'is_read', 'created_at') |
||||
|
readonly_fields = ('created_at',) |
||||
|
|
||||
|
|
||||
|
class SupportRoomAdmin(ModelAdmin): |
||||
|
list_display = ( |
||||
|
'id', |
||||
|
'display_user', |
||||
|
'display_status', |
||||
|
'unread_by_admin', |
||||
|
'unread_by_user', |
||||
|
'last_message_at', |
||||
|
'created_at', |
||||
|
) |
||||
|
list_filter = ('status', 'created_at', 'last_message_at') |
||||
|
search_fields = ('user__email', 'user__fullname', 'user__device_id') |
||||
|
readonly_fields = ('created_at', 'updated_at', 'last_message_at') |
||||
|
inlines = [SupportMessageInline] |
||||
|
list_per_page = 25 |
||||
|
|
||||
|
@display(description=_('User / Guest')) |
||||
|
def display_user(self, obj: SupportRoom): |
||||
|
if obj.user.email: |
||||
|
return f"{obj.user.fullname or ''} ({obj.user.email})".strip() |
||||
|
return f"Guest ({str(obj.user.device_id or obj.user_id)[:12]})" |
||||
|
|
||||
|
@display(description=_('Status')) |
||||
|
def display_status(self, obj: SupportRoom): |
||||
|
colors = { |
||||
|
'open': '#3B82F6', |
||||
|
'pending': '#F59E0B', |
||||
|
'answered': '#10B981', |
||||
|
'closed': '#6B7280', |
||||
|
} |
||||
|
color = colors.get(obj.status, '#6B7280') |
||||
|
return format_html( |
||||
|
'<span style="color: {}; font-weight: 600;">{}</span>', |
||||
|
color, |
||||
|
obj.get_status_display(), |
||||
|
) |
||||
|
|
||||
|
def save_formset(self, request, form, formset, change): |
||||
|
instances = formset.save(commit=False) |
||||
|
for instance in instances: |
||||
|
if isinstance(instance, SupportMessage) and not instance.pk: |
||||
|
if not instance.sender_id: |
||||
|
instance.sender = request.user |
||||
|
instance.sender_type = SupportMessage.SenderType.SUPPORT |
||||
|
instance.save() |
||||
|
formset.save_m2m() |
||||
|
|
||||
|
|
||||
|
dovoodi_admin_site.register(SupportRoom, SupportRoomAdmin) |
||||
|
project_admin_site.register(SupportRoom, SupportRoomAdmin) |
||||
@ -0,0 +1,8 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
|
||||
|
class SupportConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.support' |
||||
|
verbose_name = _('Support Chat') |
||||
@ -0,0 +1,51 @@ |
|||||
|
import django.db.models.deletion |
||||
|
from django.conf import settings |
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='SupportRoom', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('status', models.CharField(choices=[('open', 'Open'), ('pending', 'Pending Support Reply'), ('answered', 'Answered'), ('closed', 'Closed')], db_index=True, default='open', max_length=20, verbose_name='Status')), |
||||
|
('unread_by_admin', models.PositiveIntegerField(default=0, verbose_name='Unread by Admin')), |
||||
|
('unread_by_user', models.PositiveIntegerField(default=0, verbose_name='Unread by User')), |
||||
|
('last_message_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Last Message At')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
||||
|
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_support_rooms', to=settings.AUTH_USER_MODEL, verbose_name='Assigned Specialist')), |
||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='support_room', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Support Room', |
||||
|
'verbose_name_plural': 'Support Rooms', |
||||
|
'ordering': ('-last_message_at', '-created_at'), |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='SupportMessage', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('sender_type', models.CharField(choices=[('user', 'User'), ('support', 'Support Specialist')], db_index=True, default='user', max_length=16, verbose_name='Sender Type')), |
||||
|
('content', models.TextField(verbose_name='Message Content')), |
||||
|
('is_read', models.BooleanField(db_index=True, default=False, verbose_name='Is Read')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Created At')), |
||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='support.supportroom', verbose_name='Support Room')), |
||||
|
('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_support_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender User')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Support Message', |
||||
|
'verbose_name_plural': 'Support Messages', |
||||
|
'ordering': ('id',), |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1 @@ |
|||||
|
# Migrations package for apps.support |
||||
@ -0,0 +1,140 @@ |
|||||
|
from django.conf import settings |
||||
|
from django.db import models |
||||
|
from django.utils import timezone |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
|
||||
|
class SupportRoom(models.Model): |
||||
|
class StatusChoices(models.TextChoices): |
||||
|
OPEN = 'open', _('Open') |
||||
|
PENDING = 'pending', _('Pending Support Reply') |
||||
|
ANSWERED = 'answered', _('Answered') |
||||
|
CLOSED = 'closed', _('Closed') |
||||
|
|
||||
|
user = models.OneToOneField( |
||||
|
settings.AUTH_USER_MODEL, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name='support_room', |
||||
|
verbose_name=_('User'), |
||||
|
) |
||||
|
status = models.CharField( |
||||
|
max_length=20, |
||||
|
choices=StatusChoices.choices, |
||||
|
default=StatusChoices.OPEN, |
||||
|
db_index=True, |
||||
|
verbose_name=_('Status'), |
||||
|
) |
||||
|
assigned_to = models.ForeignKey( |
||||
|
settings.AUTH_USER_MODEL, |
||||
|
on_delete=models.SET_NULL, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
related_name='assigned_support_rooms', |
||||
|
verbose_name=_('Assigned Specialist'), |
||||
|
) |
||||
|
unread_by_admin = models.PositiveIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Unread by Admin'), |
||||
|
) |
||||
|
unread_by_user = models.PositiveIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Unread by User'), |
||||
|
) |
||||
|
last_message_at = models.DateTimeField( |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
db_index=True, |
||||
|
verbose_name=_('Last Message At'), |
||||
|
) |
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_('Created At'), |
||||
|
) |
||||
|
updated_at = models.DateTimeField( |
||||
|
auto_now=True, |
||||
|
verbose_name=_('Updated At'), |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Support Room') |
||||
|
verbose_name_plural = _('Support Rooms') |
||||
|
ordering = ('-last_message_at', '-created_at') |
||||
|
|
||||
|
def __str__(self): |
||||
|
user_label = self.user.email or self.user.fullname or f"Guest #{self.user_id}" |
||||
|
return f"Support Room #{self.pk} - {user_label}" |
||||
|
|
||||
|
|
||||
|
class SupportMessage(models.Model): |
||||
|
class SenderType(models.TextChoices): |
||||
|
USER = 'user', _('User') |
||||
|
SUPPORT = 'support', _('Support Specialist') |
||||
|
|
||||
|
room = models.ForeignKey( |
||||
|
SupportRoom, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name='messages', |
||||
|
verbose_name=_('Support Room'), |
||||
|
) |
||||
|
sender_type = models.CharField( |
||||
|
max_length=16, |
||||
|
choices=SenderType.choices, |
||||
|
default=SenderType.USER, |
||||
|
db_index=True, |
||||
|
verbose_name=_('Sender Type'), |
||||
|
) |
||||
|
sender = models.ForeignKey( |
||||
|
settings.AUTH_USER_MODEL, |
||||
|
on_delete=models.SET_NULL, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
related_name='sent_support_messages', |
||||
|
verbose_name=_('Sender User'), |
||||
|
) |
||||
|
content = models.TextField( |
||||
|
verbose_name=_('Message Content'), |
||||
|
) |
||||
|
is_read = models.BooleanField( |
||||
|
default=False, |
||||
|
db_index=True, |
||||
|
verbose_name=_('Is Read'), |
||||
|
) |
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
db_index=True, |
||||
|
verbose_name=_('Created At'), |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Support Message') |
||||
|
verbose_name_plural = _('Support Messages') |
||||
|
ordering = ('id',) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"[{self.sender_type}] Room #{self.room_id}: {self.content[:40]}" |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
is_new = self.pk is None |
||||
|
super().save(*args, **kwargs) |
||||
|
if is_new and self.room_id: |
||||
|
now = self.created_at or timezone.now() |
||||
|
if self.sender_type == self.SenderType.USER: |
||||
|
SupportRoom.objects.filter(pk=self.room_id).update( |
||||
|
last_message_at=now, |
||||
|
status=SupportRoom.StatusChoices.PENDING, |
||||
|
unread_by_admin=models.F('unread_by_admin') + 1, |
||||
|
updated_at=now, |
||||
|
) |
||||
|
elif self.sender_type == self.SenderType.SUPPORT: |
||||
|
SupportRoom.objects.filter(pk=self.room_id).update( |
||||
|
last_message_at=now, |
||||
|
status=SupportRoom.StatusChoices.ANSWERED, |
||||
|
unread_by_user=models.F('unread_by_user') + 1, |
||||
|
unread_by_admin=0, |
||||
|
updated_at=now, |
||||
|
) |
||||
|
SupportMessage.objects.filter( |
||||
|
room_id=self.room_id, |
||||
|
sender_type=self.SenderType.USER, |
||||
|
is_read=False, |
||||
|
).update(is_read=True) |
||||
@ -0,0 +1,57 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from apps.support.models import SupportRoom, SupportMessage |
||||
|
|
||||
|
|
||||
|
class SupportMessageSerializer(serializers.ModelSerializer): |
||||
|
sender_name = serializers.SerializerMethodField() |
||||
|
timestamp_ms = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = SupportMessage |
||||
|
fields = ( |
||||
|
'id', |
||||
|
'room', |
||||
|
'sender_type', |
||||
|
'sender_name', |
||||
|
'content', |
||||
|
'is_read', |
||||
|
'created_at', |
||||
|
'timestamp_ms', |
||||
|
) |
||||
|
read_only_fields = fields |
||||
|
|
||||
|
def get_sender_name(self, obj: SupportMessage) -> str: |
||||
|
if obj.sender_type == SupportMessage.SenderType.SUPPORT: |
||||
|
if obj.sender and obj.sender.fullname: |
||||
|
return obj.sender.fullname |
||||
|
return 'Support Specialist' |
||||
|
if obj.sender and obj.sender.fullname: |
||||
|
return obj.sender.fullname |
||||
|
return 'You' |
||||
|
|
||||
|
def get_timestamp_ms(self, obj: SupportMessage) -> int: |
||||
|
if not obj.created_at: |
||||
|
return 0 |
||||
|
return int(obj.created_at.timestamp() * 1000) |
||||
|
|
||||
|
|
||||
|
class SendSupportMessageSerializer(serializers.Serializer): |
||||
|
content = serializers.CharField( |
||||
|
required=True, |
||||
|
allow_blank=False, |
||||
|
max_length=4000, |
||||
|
trim_whitespace=True, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class SupportRoomSerializer(serializers.ModelSerializer): |
||||
|
class Meta: |
||||
|
model = SupportRoom |
||||
|
fields = ( |
||||
|
'id', |
||||
|
'status', |
||||
|
'unread_by_user', |
||||
|
'last_message_at', |
||||
|
'created_at', |
||||
|
) |
||||
|
read_only_fields = fields |
||||
@ -0,0 +1,9 @@ |
|||||
|
from django.urls import path |
||||
|
from apps.support.views import SupportChatMessagesView, SupportUnreadCountView |
||||
|
|
||||
|
app_name = 'support' |
||||
|
|
||||
|
urlpatterns = [ |
||||
|
path('messages/', SupportChatMessagesView.as_view(), name='support-messages'), |
||||
|
path('unread/', SupportUnreadCountView.as_view(), name='support-unread'), |
||||
|
] |
||||
@ -0,0 +1,146 @@ |
|||||
|
from django.db import transaction |
||||
|
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.response import Response |
||||
|
from rest_framework.views import APIView |
||||
|
|
||||
|
from apps.support.models import SupportMessage, SupportRoom |
||||
|
from apps.support.serializers import ( |
||||
|
SendSupportMessageSerializer, |
||||
|
SupportMessageSerializer, |
||||
|
SupportRoomSerializer, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def resolve_user_support_room(request, create_if_missing: bool = False): |
||||
|
""" |
||||
|
Resolves the SupportRoom for request.user (works for both logged-in and guest users). |
||||
|
If a logged-in user does not yet have a room, checks the optional X-Guest-Token header |
||||
|
to seamlessly migrate their existing guest support conversation. |
||||
|
""" |
||||
|
user = request.user |
||||
|
room = SupportRoom.objects.filter(user=user).first() |
||||
|
if room: |
||||
|
return room |
||||
|
|
||||
|
# Check if user recently logged in from a guest session that already had a support chat |
||||
|
guest_token_key = request.headers.get('X-Guest-Token', '').strip() |
||||
|
if guest_token_key and not getattr(user, 'is_guest', False): |
||||
|
guest_token_obj = ( |
||||
|
Token.objects.select_related('user') |
||||
|
.filter(key=guest_token_key) |
||||
|
.first() |
||||
|
) |
||||
|
if guest_token_obj and guest_token_obj.user_id != user.id: |
||||
|
guest_room = SupportRoom.objects.filter(user=guest_token_obj.user).first() |
||||
|
if guest_room: |
||||
|
guest_room.user = user |
||||
|
guest_room.save(update_fields=['user', 'updated_at']) |
||||
|
return guest_room |
||||
|
|
||||
|
if create_if_missing: |
||||
|
room, _ = SupportRoom.objects.get_or_create(user=user) |
||||
|
return room |
||||
|
|
||||
|
return None |
||||
|
|
||||
|
|
||||
|
class SupportChatMessagesView(APIView): |
||||
|
""" |
||||
|
GET /api/support/messages/?after_id=<id> |
||||
|
Returns the user's support room and messages (all or after `after_id` for short polling). |
||||
|
POST /api/support/messages/ |
||||
|
Sends a new message from the user (or guest user) to support. |
||||
|
""" |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
|
||||
|
def get(self, request, *args, **kwargs): |
||||
|
room = resolve_user_support_room(request, create_if_missing=False) |
||||
|
if not room: |
||||
|
return Response( |
||||
|
{ |
||||
|
'room': None, |
||||
|
'messages': [], |
||||
|
}, |
||||
|
status=status.HTTP_200_OK, |
||||
|
) |
||||
|
|
||||
|
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 support replies as read when user fetches messages in open chat |
||||
|
mark_read = request.query_params.get('mark_read', 'true').lower() != 'false' |
||||
|
if mark_read and (room.unread_by_user > 0 or any( |
||||
|
m.sender_type == SupportMessage.SenderType.SUPPORT and not m.is_read |
||||
|
for m in messages_list |
||||
|
)): |
||||
|
SupportMessage.objects.filter( |
||||
|
room=room, |
||||
|
sender_type=SupportMessage.SenderType.SUPPORT, |
||||
|
is_read=False, |
||||
|
).update(is_read=True) |
||||
|
if room.unread_by_user != 0: |
||||
|
room.unread_by_user = 0 |
||||
|
room.save(update_fields=['unread_by_user', 'updated_at']) |
||||
|
|
||||
|
return Response( |
||||
|
{ |
||||
|
'room': SupportRoomSerializer(room).data, |
||||
|
'messages': SupportMessageSerializer(messages_list, many=True).data, |
||||
|
}, |
||||
|
status=status.HTTP_200_OK, |
||||
|
) |
||||
|
|
||||
|
@transaction.atomic |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
serializer = SendSupportMessageSerializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
|
||||
|
room = resolve_user_support_room(request, create_if_missing=True) |
||||
|
message = SupportMessage.objects.create( |
||||
|
room=room, |
||||
|
sender_type=SupportMessage.SenderType.USER, |
||||
|
sender=request.user, |
||||
|
content=serializer.validated_data['content'], |
||||
|
) |
||||
|
room.refresh_from_db() |
||||
|
|
||||
|
return Response( |
||||
|
{ |
||||
|
'room': SupportRoomSerializer(room).data, |
||||
|
'message': SupportMessageSerializer(message).data, |
||||
|
}, |
||||
|
status=status.HTTP_201_CREATED, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class SupportUnreadCountView(APIView): |
||||
|
""" |
||||
|
GET /api/support/unread/ |
||||
|
Lightweight endpoint to check if the user has unread support specialist replies. |
||||
|
""" |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
|
||||
|
def get(self, request, *args, **kwargs): |
||||
|
room = resolve_user_support_room(request, create_if_missing=False) |
||||
|
return Response( |
||||
|
{ |
||||
|
'unread_count': room.unread_by_user if room else 0, |
||||
|
'status': room.status if room else None, |
||||
|
}, |
||||
|
status=status.HTTP_200_OK, |
||||
|
) |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue