Browse Source

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 URLs
master
Mohsen Taba 2 weeks ago
parent
commit
46864b1df9
  1. 2
      apps/support/__init__.py
  2. 67
      apps/support/admin.py
  3. 8
      apps/support/apps.py
  4. 51
      apps/support/migrations/0001_initial.py
  5. 1
      apps/support/migrations/__init__.py
  6. 140
      apps/support/models.py
  7. 57
      apps/support/serializers.py
  8. 9
      apps/support/urls.py
  9. 146
      apps/support/views.py
  10. 1
      config/settings/base.py
  11. 1
      config/urls.py

2
apps/support/__init__.py

@ -0,0 +1,2 @@
# Support chat application
default_app_config = 'apps.support.apps.SupportConfig'

67
apps/support/admin.py

@ -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)

8
apps/support/apps.py

@ -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')

51
apps/support/migrations/0001_initial.py

@ -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',),
},
),
]

1
apps/support/migrations/__init__.py

@ -0,0 +1 @@
# Migrations package for apps.support

140
apps/support/models.py

@ -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)

57
apps/support/serializers.py

@ -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

9
apps/support/urls.py

@ -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'),
]

146
apps/support/views.py

@ -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,
)

1
config/settings/base.py

@ -58,6 +58,7 @@ LOCAL_APPS = [
'apps.article.apps.ArticleConfig',
'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
'apps.agent.apps.AgentConfig',
'apps.support.apps.SupportConfig',
'dynamic_preferences',
'apps.geolocation_package',

1
config/urls.py

@ -93,6 +93,7 @@ api_patterns = [
path('article/', include('apps.article.urls')),
path('podcast/', include('apps.podcast.urls')),
path('agent/', include('apps.agent.urls')),
path('support/', include('apps.support.urls')),
path('bookmarks/', include('apps.bookmark.urls')),
path('calendar/', include('apps.dobodbi_calendar.urls')),
path('admin/dashboard-stats/', AdminDashboardStatsView.as_view()),

Loading…
Cancel
Save