Django based help desk ticketing system

Alex Blashchuk | Oct. 31, 2023 | Category: DJANGO

WHAT IS “DJANGO-CHANNELS”

Recently, I had a problem: "To create a real-time platform for communication between customers and the support team based on Django." Luckily, Django has a perfect library, "Django-channels." This library provides us with WebSocket connections between the client-side and server-side.

As I mentioned, Django-channels is an extension to the Django web framework that enables the development of real-time web applications. While Django is excellent for building traditional request-response web applications, it is not inherently suited for handling WebSocket connections or long-polling requests. Django-channels addresses this limitation by providing a framework for handling real-time communication, making it possible to create chat applications, live notifications, collaborative editing tools, and more.

I hope you already know what a traditional request-response web application is: REQUEST FROM CLIENT → SERVER → RESPONSE FROM SERVER → CLIENT → DONE.

When we talk about "real-time web applications and long-polling requests," a "real-time web application" is a type of web application that provides different live actions, immediate responses to client actions, live updates to users as events occur, without the need to refresh the page. Common use cases for real-time applications include online chat, online gaming, collaborative tools, financial trading platforms, and more. "Long-polling" is one of the techniques used to achieve real-time communication between a web server and a client. It works as follows:

The client initiates a request: a simple HTTP request to the server.
The server holds the request. In the traditional Django case, the server responds immediately to the request. But using Django-channels, the server keeps the connection open and waits for new data from the client-side.
When there is new data or an event to be delivered to the client, the server responds to the request with the updated information.
The client processes the response. The client receives the response and processes the data or event.
This process is repeated.
Using "high-level" documentation slang: "Channels change Django to weave asynchronous code underneath and through Django's synchronous core, allowing Django projects to handle not only HTTP but also protocols that require long-running connections, such as Web Sockets, MQTT, chatbots, amateur radio, and more. Our project will run under SGI, instead of WSGI. In simple words, we can write asynchronous code, use Django's auth system, session system, and more.

WHAT WAS MY GOAL

So, I needed a new and simple CRM system for my team. I know a lot of different CRM systems that basically cover all my requirements. But there are several things that these CRM systems can't handle:

Cleanliness: All CRM systems that I've encountered were overloaded with different information, graphs, tables, windows with statistics, etc. Therefore, there is a lot of information noise on the screen, which makes it difficult for a new user to understand what information is important and what is secondary.

Easy response to the client: Many CRM systems have integration with email, Telegram, Viber, WhatsApp, and so on. But all of these chats are mostly divided into different tabs or windows. You can make over 15 clicks between tabs before you answer the client, instead of "open request → type answer → send → done → open the second one → repeat → open the third one → repeat → …."

Simplicity: It must be simple and clear to understand how to work with the system, how to handle client requests, and respond to them.

Easy integration into your department: Like "Plug and Play."

Of course, all these statements are valid if your company does not strive for all corporate standards but needs to organize the work of the customer relations department. So do I. Unfortunately, I haven't found such a CRM system, so I've decided to create it myself 😎.

LET’S START

First, I have to create a Django project. It’s a simple and common thing.

django-admin startproject ticket_server

Second, I have to create an application for my ticket dashboard. This will create a directory “dashboard”.

python manage.py startapp dashboard

And I got next directory structure:

ticket_server/
    manage.py
    ticket_server/
        __init__.py
        settings.py
        urls.py
        asgi.py
        wsgi.py
    dashboard/
        __init__.py
        admin.py
        apps.py
        migrations/
            __init__.py
        models.py
        tests.py
        urls.py
        views.py

Third, configure URLconf and add dashboard application to INSTALLED_APPS in settings.py file. Create a file called urls.py in dashboard directory. Modify urls.py in ticket_server directory.

ticket_server/urls.py
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('dashboard.urls', namespace='dashboard')),
]
ticket_server/settings.py
INSTALLED_APPS = (
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.sites",
    ...

    "dashboard",
)

Next, I will implement my models in dashboard/models.py.

  1. Message – this model will contain every message from client or team member.
  2. Attachments – this model will contain files from clients.
  3. Ticket – this model will contain all messages from clients and team members on the same subject. It’ll be something like a chat room.

Messages model:

dashboard/models.py
class Message(models.Model):
    author = models.ForeignKey(Users, on_delete=models.CASCADE)
    create = models.DateTimeField(auto_now_add=True, db_comment='Used for date')
    ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE)
    text = models.TextField(blank=True, null=True)
    attachments = models.ManyToManyField(Attachments)

Attachments model:

dashboard/models.py
class Attachments(models.Model):
    file = models.FileField()

Ticket model:

dashboard/models.py
class Ticket(models.Model):
    CREATED = 1
    IN_PROGRESS = 2
    ANSWERED = 3
    DONE = 4
    STATUS = (
        (CREATED, 'Created'),
        (IN_PROGRESS, 'In progress'),
        (ANSWERED, 'Answered'),
        (DONE, 'Done'),
    )
    subject = models.CharField(max_length=128, blank=True)
    status = models.IntegerField(choices=STATUS, default=1)
    contact = models.ForeignKey(Users, on_delete=models.CASCADE, blank=True, related_name='ticket_contact')
    author = models.ForeignKey(Users, on_delete=models.PROTECT, blank=True, related_name='ticket_author')
    new_message = models.BooleanField(default=False)

Next I'm going to add Telegram Bot support to my system using the pyTelegramBotAPI lib. I want to wrap polling with an infinite loop and exception handling to avoid the bot from stopping polling. Let’s create a directory in dashboard “managment/commands”.

dashboard/
    __init__.py
    admin.py
    apps.py
    migrations/
        __init__.py
    managment/
        __init__.py
        commands/
            __init__.py
    models.py
    tests.py
    urls.py
    views.py

Create a file “telegram_bot_handler.py” in commands directory.

managment/
    __init__.py
    commands/
        __init__.py
        telegram_bot_handler.py

telegram_bot_handler.py file:

dashboard/managment/commands/telegram_bot_handler.py
from django.core.management.base import BaseCommand
from django.conf import settings
from telebot import TeleBot
from dashboard.models import Ticket, Message, Attachments
from users.models import Users

from common.tools.channel_tools import ChannelTools

bot = TeleBot(settings.TELEBOT_API_KEY, threaded=False)

class Command(BaseCommand):
    help = 'Implemented to Django application telegram bot setup command'

    def handle(self, *args, **kwargs):
        bot.enable_save_next_step_handlers(delay=2)
        bot.load_next_step_handlers()


        @bot.message_handler(commands=['start', 'help'])
        def send_welcome(message):
            bot.reply_to(message, "Howdy, how are you doing?")

        @bot.message_handler(content_types=['photo'])
        def photo_message(message):
            pass

        @bot.message_handler(content_types=['document'])
        def file_message(message):
            pass

        @bot.message_handler(content_types=['text'])
        def message_from_user(message):
            pass

        bot.infinity_polling()

Now let's check if the telegram bot works fine. I will use the following command:

py manage.py telegram_bot_control

After this, the telegram bot will start, and should respond to the "\start" command with "Howdy, how are you doing?".

I'm ready to add Django-channels to the project. According to the Django-channels documentation. Channels is available on PyPI - to install it run:

python -m pip install -U 'channels[daphne]'

This will install Channels together with the Daphne ASGI application server. If you wish to use a different application server you can `pip install channels`, without the optional `daphne` add-on.

Once that’s done, I'll add "daphne" to the beginning of the INSTALLED_APPS setting:

ticket_server/settings.py
INSTALLED_APPS = (
    "daphne",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.sites",
    ...
)

This will install the Daphne’s ASGI version of the 'runserver' management command.

Set the ASGI_APPLICATION setting in ticket_server/settings.py to point to that routing object as the root application:

ticket_server/settings.py
ASGI_APPLICATION = "myproject.asgi.application"

The second important thing I have to do is add the routing.py file to the dashboard application directory. In this file I will do routing configuration for the Django-channels. Routing configuration is similar to the urls.py, it tells Django-channels what code to run when an HTTP request is received.

Create the file dashboard/routing.py with following code:

dashboard/routing.py
from django.urls import re_path


from . import consumers


websocket_urlpatterns = [
   re_path(r"ws/$", consumers.TicketConsumers.as_asgi()),
   re_path(r"ws/chat/(?P<chat_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
]

I haven’t create the consumers.py file yet. Let's leave it as it is for now. I call the as_asgi() classmethod in order to get an ASGI application that will instantiate an instance of our consumer for each user-connection. This is similar to Django’s as_view(), which plays the same role for per-request Django view instances.

The next step is to point the main ASGI configuration at the chat.routing module. In mysite/asgi.py, import AuthMiddlewareStack, URLRouter, and chat.routing; and insert a 'websocket' key in the ProtocolTypeRouter list in the following format:

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application

import dashboard.routing as dr

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ticket_server.settings')

django_asgi_app = get_asgi_application()

application = ProtocolTypeRouter({
    'http': django_asgi_app,
    'websocket': AllowedHostsOriginValidator(
        AuthMiddlewareStack(
            URLRouter(
                dr.websocket_urlpatterns
            )
        )
    ),
})

Let's get to consumers. What are consumers? Let's look at a typical Django project. As I mentioned earlier,  when Django accepts an HTTP request from a client, it looks through URLconf to find a view function and then calls that view function to handle the request and return a response to the client. Django-channels follows a similar pattern, but uses consumers instead of views. When Django-channels accepts a WebSocket connection from a client, it looks through the routing conf to find a consumer and then calls various functions on the consumer to handle any eventa from the connection.

I will create a new file dashboard/consumers.py, where add two consumers:

  • TicketConsumer - to handle events from the main page of the dashboard
  • ChatConsumer - to handle events from chats
dashboard/consumers.py
import json

from asgiref.sync import sync_to_async
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer

from telebot import TeleBot
from .models import Ticket, Message
from users.models import Users

from django.conf import settings
from django.core.mail import send_mail

bot = TeleBot(settings.TELEBOT_API_KEY, threaded=False)


class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['chat_name']
        self.room_group_name = 'chat_%s' % self.room_name
        self.user = self.scope['user']

        # Join room group
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )
        await self.accept()

    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )

    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        tag_from_admin = text_data_json['tag_from_admin']

        # Save message to db
        ticket, msg, contact = await self.save_message_to_db(user=self.user, text=message,
                                            chat_id=self.scope['url_route']['kwargs']['chat_name'], )
        # Send message to contact
        await self.send_to_contact(ticket, msg, contact)

        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
                ...
            }
        )

    # Receive message from room group
    async def chat_message(self, event):
        message = event['message']
        ...

        # Send message to WebSocket
        await self.send(
            text_data=json.dumps(
                {
                    'message': message,
                    ...
                }
            )
        )

    @database_sync_to_async
    def save_message_to_db(self, user, text, chat_id):
        # some logic for saving to the database
        return ticket, msg, contact

    @sync_to_async
    def send_to_contact(self, ticket, msg, contact):
        # some logic for sending messages to the client

Ticket consumer:

dashboard/consumers.py
class TicketConsumers(AsyncWebsocketConsumer):
    async def connect(self):
        self.dashboard = 'dashboard'
        self.dashboard_group = 'group_{}'.format(self.dashboard)

        await self.channel_layer.group_add(
            self.dashboard_group,
            self.channel_name
        )

        await self.accept()

    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(self.dashboard_group, self.channel_name)

    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        subject = text_data_json["subject"]
        contact_username = text_data_json["contact_username"]

        ticket = await self.save_ticket_to_db(
            user=self.scope['user'].username,
            contact_username=contact_username,
            subject=subject
        )

        # Send message to room group
        await self.channel_layer.group_send(
            self.dashboard_group,
            {
                "type": "dashboard.message",
                "subject": ticket.subject,
                ...
            }
        )

    # Receive message from room group
    async def dashboard_message(self, event):
        subject = event["subject"]
        ...

        # Send message to WebSocket
        await self.send(
            text_data=json.dumps(
                {
                    "subject": subject,
                    ...
                }
            )
        )

    async def update_dashboard(self, event):
        message_id = event["message_id"]

        await self.send(
            text_data=json.dumps(
                {
                    "message_id": message_id,
                }
            )
        )

    @database_sync_to_async
    def save_ticket_to_db(self, user, contact_username, subject):
        # some logic for saving to the database
        return ticket

We need to return to the telegram bot file - telegram_bot_control.py. I will add some mixins with consumer logic to make it more complex and easier to use in several places in my code. Let's create a file common/tools/channel_tools.py.

ticket_server/common/tools/channel_tools.py
import channels.layers
from asgiref.sync import async_to_sync


class ChannelTools:
    def __init__(self, group_name, dashboard_group):
        self.group_name = group_name
        self.dashboard_group = dashboard_group
        self.channel_layer = channels.layers.get_channel_layer()

    def send_to_group(self, message, contact, tag_from_admin, file_list):
        async_to_sync(self.channel_layer.group_send)(
            self.group_name, {
                'type': 'chat_message',
                'message': message.text,
                ...
            }
        )
        message.ticket.change_ticket_status(2)
        message.ticket.set_new_message(True)

        async_to_sync(self.channel_layer.group_send)(
            self.dashboard_group, {
                "type": "update.dashboard",
                "message_id": message.ticket.id,
            }
        )
        return

    def update_ticket_list(self, ticket, contact):
        async_to_sync(self.channel_layer.group_send)(
            self.dashboard_group, {
                "type": "dashboard.message",
                "subject": ticket.subject,
                ...
            }
        )
        return

I'll edit telegram_bot_control.py to include logic for working with ChatConsumer and TicketConsumer. I want Telegram handler to send events to Websocket, allowing the admin to receive new chat messages and new tickets.

dashboard/management/commands/telegram_bot_control.py
from django.core.management.base import BaseCommand
from django.conf import settings
from telebot import TeleBot
from dashboard.models import Ticket, Message, Attachments
from users.models import Users

from common.tools.channel_tools import ChannelTools

bot = TeleBot(settings.TELEBOT_API_KEY, threaded=False)


class Command(BaseCommand):
    help = 'Implemented to Django application telegram bot setup command'

    def handle(self, *args, **kwargs):
        bot.enable_save_next_step_handlers(delay=2)
        bot.load_next_step_handlers()

        @bot.message_handler(commands=['start', 'help'])
        def send_welcome(message):
            bot.reply_to(message, "Howdy, how are you doing?")

        @bot.message_handler(content_types=['photo'])
        def photo_message(message):
            username = f'{message.from_user.first_name} [{message.from_user.username}]'
            chat_id = message.chat.id
            text = message.caption

            contact = get_contact(username, chat_id)
            ticket = get_ticket(contact)
            msg = get_msg(contact, ticket, text)

            img_list = save_attachments(message, msg)

            channels_tools = ChannelTools(
                group_name=f"chat_{ticket[0].id}",
                dashboard_group='group_dashboard'
            )

            # Send message to chat_group
            channels_tools.send_to_group(
                message=msg,
                contact=contact[0],
                tag_from_admin=0,
                file_list=img_list
            )

            # Update ticket list if new ticket
            if ticket[1]:
                channels_tools.update_ticket_list(
                    ticket=ticket[0],
                    contact=contact[0]
                )

        @bot.message_handler(content_types=['document'])
        def file_message(message):
            username = f'{message.from_user.first_name} [{message.from_user.username}]'
            chat_id = message.chat.id
            text = message.caption

            contact = get_contact(username, chat_id)
            ticket = get_ticket(contact)
            msg = get_msg(contact, ticket, text)

            file_list = save_attachments(message, msg)

            channels_tools = ChannelTools(
                group_name=f"chat_{ticket[0].id}",
                dashboard_group='group_dashboard'
            )

            # Send message to chat_group
            channels_tools.send_to_group(
                message=msg,
                contact=contact[0],
                tag_from_admin=0,
                file_list=file_list
            )

            # Update ticket list if new ticket
            if ticket[1]:
                channels_tools.update_ticket_list(
                    ticket=ticket[0],
                    contact=contact[0]
                )

        @bot.message_handler(content_types=['text'])
        def message_from_user(message):
            username = f'{message.from_user.first_name} [{message.from_user.username}]'
            chat_id = message.chat.id
            text = message.text

            contact = get_contact(username, chat_id)
            ticket = get_ticket(contact)
            msg = get_msg(contact, ticket, text)

            channels_tools = ChannelTools(
                group_name=f"chat_{ticket[0].id}",
                dashboard_group='group_dashboard'
            )

            # Send message to chat_group
            channels_tools.send_to_group(
                message=msg,
                contact=contact[0],
                tag_from_admin=0,
                file_list=None
            )

            # Update ticket list if new ticket
            if ticket[1]:
                channels_tools.update_ticket_list(
                    ticket=ticket[0],
                    contact=contact[0]
                )

        def save_attachments(message, msg):
            msg_att = dict()
            file_type = message.content_type
            if file_type == 'photo':
                file = message.photo[-1]
                file_name = file.file_id + '.jpg'
            else:
                file = message.document
                file_name = file.file_name
            file_id = bot.get_file(file.file_id)
            saved_file = bot.download_file(file_id.file_path)
            with open(f'media\\{file_type}\\{file_name}', 'wb') as f:
                f.write(saved_file)
            att = Attachments.objects.create(file=f'{file_type}\\{file_name}')
            msg.attachments.add(att)

            msg_att.update({'url': att.file.url, 'file_name': att.get_file_name(), 'file_type': att.get_file_type()})
            return msg_att

        def get_contact(username, contact_id):
            return Users.objects.get_or_create(
                username=username,
                contact_id=contact_id,
                contact_type=1
            )

        def get_ticket(contact):
            return Ticket.objects.get_or_create(
                subject=contact[0].username,
                author=contact[0],
                contact=contact[0]
            )

        def get_msg(contact, ticket, text):
            return Message.objects.create(
                author=contact[0],
                ticket=ticket[0],
                text=text,
            )

        bot.infinity_polling()

The last thing what I will do is to create HTML templates for chat page and ticket page. We need to add next JavaScript script to the templates:

<script>
        'use strict';
        let permission = Notification.permission;
        const roomName = JSON.parse(document.getElementById('room-name').textContent);

        {#Use a chatSocket in a chat html template#}
        const chatSocket = new WebSocket(
          'ws://'
          + window.location.host
          + '/ws/chat/'
          + roomName
          + '/'
        );

        {#Use a dashboardSocket in a ticket html template#}
        const dashboardSocket = new WebSocket(
          'ws://'
          + window.location.host
          + '/ws/'
        );    

        chatSocket.onmessage = function(e) {
            const data = JSON.parse(e.data);
            {#Some logic for page context updating#}
        };

        chatSocket.onclose = function(e) {
            console.error('Chat socket closed unexpectedly');
        };

        document.querySelector('#chat-message-input').focus();
        document.querySelector('#chat-message-input').onkeyup = function(e) {
            if (e.key === 'Enter') {  // enter, return
                document.querySelector('#chat-message-submit').click();
            }
        };

        document.querySelector('#chat-message-submit').onclick = function(e) {
            const messageInputDom = document.querySelector('#chat-message-input');
            const message = messageInputDom.value;
            chatSocket.send(JSON.stringify({
                'message': message,
                ...
            }));
            messageInputDom.value = '';
        };
    </script>

The chatSocket and dashboardSocket variables are used to declare the WebSocket url that the client wants to connect to. We have these URLs defined in the routing.py file

websocket_urlpatterns = [
   re_path(r"ws/$", consumers.TicketConsumers.as_asgi()),  # 'ws://' + window.location.host + '/ws/'
   re_path(r"ws/chat/(?P<chat_name>\w+)/$", consumers.ChatConsumer.as_asgi()),  # 'ws://' + window.location.host + '/ws/chat/' + roomName + '/'
]

Now the ticket system for the support team is ready. Run the server and execute telegtam_bot_control.py and let's chek what we've got:

Woah! I received a message from my client. And it is highlighted in yellow, wich indicates that I have unchecked messages.

Well, let's help him.

I have checked the file and helped him in a minute. And now I can mark this ticket as "Done".

If you have any questions or suggestions, feel free to contact me at blashchuk.alexander@gmail.com.