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