From 46864b1df9b69c5a549214761df5565c27d43618 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sat, 17 Oct 2026 14:30:40 +0330 Subject: [PATCH] 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 --- apps/support/__init__.py | 2 + apps/support/admin.py | 67 +++++++++++ apps/support/apps.py | 8 ++ apps/support/migrations/0001_initial.py | 51 +++++++++ apps/support/migrations/__init__.py | 1 + apps/support/models.py | 140 +++++++++++++++++++++++ apps/support/serializers.py | 57 +++++++++ apps/support/urls.py | 9 ++ apps/support/views.py | 146 ++++++++++++++++++++++++ config/settings/base.py | 1 + config/urls.py | 1 + 11 files changed, 483 insertions(+) create mode 100644 apps/support/__init__.py create mode 100644 apps/support/admin.py create mode 100644 apps/support/apps.py create mode 100644 apps/support/migrations/0001_initial.py create mode 100644 apps/support/migrations/__init__.py create mode 100644 apps/support/models.py create mode 100644 apps/support/serializers.py create mode 100644 apps/support/urls.py create mode 100644 apps/support/views.py diff --git a/apps/support/__init__.py b/apps/support/__init__.py new file mode 100644 index 0000000..119b117 --- /dev/null +++ b/apps/support/__init__.py @@ -0,0 +1,2 @@ +# Support chat application +default_app_config = 'apps.support.apps.SupportConfig' diff --git a/apps/support/admin.py b/apps/support/admin.py new file mode 100644 index 0000000..32289a8 --- /dev/null +++ b/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( + '{}', + 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) diff --git a/apps/support/apps.py b/apps/support/apps.py new file mode 100644 index 0000000..b4962a1 --- /dev/null +++ b/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') diff --git a/apps/support/migrations/0001_initial.py b/apps/support/migrations/0001_initial.py new file mode 100644 index 0000000..6210517 --- /dev/null +++ b/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',), + }, + ), + ] diff --git a/apps/support/migrations/__init__.py b/apps/support/migrations/__init__.py new file mode 100644 index 0000000..4e08fe0 --- /dev/null +++ b/apps/support/migrations/__init__.py @@ -0,0 +1 @@ +# Migrations package for apps.support diff --git a/apps/support/models.py b/apps/support/models.py new file mode 100644 index 0000000..035a65c --- /dev/null +++ b/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) diff --git a/apps/support/serializers.py b/apps/support/serializers.py new file mode 100644 index 0000000..70e4ae4 --- /dev/null +++ b/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 diff --git a/apps/support/urls.py b/apps/support/urls.py new file mode 100644 index 0000000..e51e6d4 --- /dev/null +++ b/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'), +] diff --git a/apps/support/views.py b/apps/support/views.py new file mode 100644 index 0000000..f86b55d --- /dev/null +++ b/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= + 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, + ) diff --git a/config/settings/base.py b/config/settings/base.py index 39c5f33..2acb750 100644 --- a/config/settings/base.py +++ b/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', diff --git a/config/urls.py b/config/urls.py index 7428ec2..bf00ea6 100644 --- a/config/urls.py +++ b/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()),