14

I've got Django Channels 2.1.2 set up in my Django app by following a tutorial and now need to set up a notification system for new messages. I want to do this in the simplest way possible.

I can do it via browser push notifications, but I don't want to do it like that. I want it to be like Stack Overflow, where there is a red number representing the instance of a new message.

One answer on here said

For notifications you only need two models: User and Notification. On connect set the scope to the currently authenticated user. Set up a post_save signal on your Notification model to trigger a consumer method to message the notification object's user. –

I am struggling to wrap my head around what this would look like, I already have a User model but no Notification one.

The chat is between only 2 users, it is not a chat room but more of a chat thread. The 2 html templates are inbox.html and thread.html

Appreciate any help!

My Django Channels code is below!

consumers.py

class ChatConsumer(AsyncConsumer):
    async def websocket_connect(self, event):
        print('connected', event)

        other_user = self.scope['url_route']['kwargs']['username']
        me = self.scope['user']
        #print(other_user, me)
        thread_obj = await self.get_thread(me, other_user)
        self.thread_obj = thread_obj
        chat_room = f"thread_{thread_obj.id}"
        self.chat_room = chat_room
        # below creates the chatroom
        await self.channel_layer.group_add(
            chat_room,
            self.channel_name
        )

        await self.send({
            "type": "websocket.accept"
        })

    async def websocket_receive(self, event):
        # when a message is recieved from the websocket
        print("receive", event)

        message_type = event.get('type', None)  #check message type, act accordingly
        if message_type == "notification_read":
            # Update the notification read status flag in Notification model.
            notification = Notification.object.get(id=notification_id)
            notification.notification_read = True
            notification.save()  #commit to DB
            print("notification read")

        front_text = event.get('text', None)
        if front_text is not None:
            loaded_dict_data = json.loads(front_text)
            msg =  loaded_dict_data.get('message')
            user = self.scope['user']
            username = 'default'
            if user.is_authenticated:
                username = user.username
            myResponse = {
                'message': msg,
                'username': username,
            }
            await self.create_chat_message(user, msg)

            # broadcasts the message event to be sent, the group send layer
            # triggers the chat_message function for all of the group (chat_room)
            await self.channel_layer.group_send(
                self.chat_room,
                {
                    'type': 'chat_message',
                    'text': json.dumps(myResponse)
                }
            )
    # chat_method is a custom method name that we made
    async def chat_message(self, event):
        # sends the actual message
        await self.send({
                'type': 'websocket.send',
                'text': event['text']
        })

    async def websocket_disconnect(self, event):
        # when the socket disconnects
        print('disconnected', event)

    @database_sync_to_async
    def get_thread(self, user, other_username):
        return Thread.objects.get_or_new(user, other_username)[0]

    @database_sync_to_async
    def create_chat_message(self, me, msg):
        thread_obj = self.thread_obj
        return ChatMessage.objects.create(thread=thread_obj, user=me, message=msg)

manager

class ThreadManager(models.Manager):
    def by_user(self, user):
        qlookup = Q(first=user) | Q(second=user)
        qlookup2 = Q(first=user) & Q(second=user)
        qs = self.get_queryset().filter(qlookup).exclude(qlookup2).distinct()
        return qs

    # method to grab the thread for the 2 users
    def get_or_new(self, user, other_username): # get_or_create
        username = user.username
        if username == other_username:
            return None, None
        # looks based off of either username
        qlookup1 = Q(first__username=username) & Q(second__username=other_username)
        qlookup2 = Q(first__username=other_username) & Q(second__username=username)
        qs = self.get_queryset().filter(qlookup1 | qlookup2).distinct()
        if qs.count() == 1:
            return qs.first(), False
        elif qs.count() > 1:
            return qs.order_by('timestamp').first(), False
        else:
            Klass = user.__class__
            try:
                user2 = Klass.objects.get(username=other_username)
            except Klass.DoesNotExist:
                user2 = None
            if user != user2:
                obj = self.model(
                        first=user,
                        second=user2
                    )
                obj.save()
                return obj, True
            return None, False

models.py

class Thread(models.Model):
    first        = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='chat_thread_first')
    second       = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='chat_thread_second')
    updated      = models.DateTimeField(auto_now=True)
    timestamp    = models.DateTimeField(auto_now_add=True)

    objects      = ThreadManager()

    def __str__(self):
        return f'{self.id}'

    @property
    def room_group_name(self):
        return f'chat_{self.id}'

    def broadcast(self, msg=None):
        if msg is not None:
            broadcast_msg_to_chat(msg, group_name=self.room_group_name, user='admin')
            return True
        return False

class ChatMessage(models.Model):
    thread      = models.ForeignKey(Thread, null=True, blank=True, on_delete=models.SET_NULL)
    user        = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name='sender', on_delete=models.CASCADE)
    message     = models.TextField()
    timestamp   = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f'{self.id}'

class Notification(models.Model):
    notification_user = models.ForeignKey(User, on_delete=models.CASCADE)
    notification_chat = models.ForeignKey(ChatMessage, on_delete=models.CASCADE)
    notification_read = models.BooleanField(default=False)

    def __str__(self):
        return f'{self.id}'

views.py

class InboxView(LoginRequiredMixin, ListView):
    template_name = 'chat/inbox.html'
    context_object_name = 'threads'
    def get_queryset(self):
        return Thread.objects.by_user(self.request.user).exclude(chatmessage__isnull=True).order_by('timestamp')
        # by_user(self.request.user)

class ThreadView(LoginRequiredMixin, FormMixin, DetailView):
    template_name = 'chat/thread.html'
    form_class = ComposeForm
    success_url = '#'

    def get_queryset(self):
        return Thread.objects.by_user(self.request.user)

    def get_object(self):
        other_username  = self.kwargs.get("username")
        obj, created    = Thread.objects.get_or_new(self.request.user, other_username)
        if obj == None:
            raise Http404
        return obj

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['form'] = self.get_form()
        return context

    def post(self, request, *args, **kwargs):
        if not request.user.is_authenticated:
            return HttpResponseForbidden()
        self.object = self.get_object()
        form = self.get_form()
        if form.is_valid():
            return self.form_valid(form)
        else:
            return self.form_invalid(form)

    def form_valid(self, form):
        thread = self.get_object()
        user = self.request.user
        message = form.cleaned_data.get("message")
        ChatMessage.objects.create(user=user, thread=thread, message=message)
        return super().form_valid(form)

thread.html

{% block head %}
<title>Chat</title>
<script src="{% static '/channels/js/websocketbridge.js' %}" type="text/javascript"></script>
{% endblock %}


    {% block content %}
<script>
  $(#notification-element).on("click", function(){
      data = {"type":"notification_read", "username": username, "notification_id": notification_id};
      socket.send(JSON.stringify(data));
  });
</script>

<!-- back to inbox button with notification example -->
        <a class="btn btn-light" id="notification_id" href="{% url 'chat:inbox' %}">Back to Inbox</a>

    <div class="msg_history">
          {% for chat in object.chatmessage_set.all %}
          {% if chat.user == user %}
          <div class="outgoing_msg">
            <div class="outgoing_msg_img"> <img src="{{ chat.user.profile.image.url }}"> </div>
            <div class="sent_msg">
              <p>{{ chat.message }}</p>
              <span class="time_date"> {{ chat.timestamp }}</span>
            </div>
          </div>
          {% else %}
          <div class="incoming_msg">
            <div class="incoming_msg_img"> <img src="{{ chat.user.profile.image.url }}"> </div>
            <div class="received_msg">
              <div class="received_withd_msg">
                <p>{{ chat.message }}</p>
                <span class="time_date"> {{ chat.timestamp }}</span>
              </div>
            </div>
          </div>
          {% endif %}
          {% endfor %}
        </div>
        <div class="type_msg">
          <div class="input_msg_write">
            <!-- text input / write message form -->
            <form id='form' method='POST'>
              {% csrf_token %}
              <input type='hidden' id='myUsername' value='{{ user.username }}' />
              {{ form.as_p }}
              <center><button type="submit" class='btn btn-success disabled' value="Send">Send</button></center>
            </form>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
{% endblock %}

{% block script %}

<script src='https://cdnjs.cloudflare.com/ajax/libs/reconnecting-websocket/1.0.0/reconnecting-websocket.js'></script>

<script>
  // websocket scripts - client side*
  var loc = window.location
  var formData = $("#form")
  var msgInput = $("#id_message")
  var chatHolder = $('#chat-items')
  var me = $('#myUsername').val()

  var wsStart = 'ws://'
  if (loc.protocol == 'https:') {
    wsStart = 'wss://'
  }
  var endpoint = wsStart + loc.host + loc.pathname
  var socket = new ReconnectingWebSocket(endpoint)

  // below is the message I am receiving
  socket.onmessage = function(e) {
    console.log("message", e)
    var data = JSON.parse(event.data);
    // Find the notification icon/button/whatever and show a red dot, add the notification_id to element as id or data attribute.
    var chatDataMsg = JSON.parse(e.data)
    chatHolder.append('<li>' + chatDataMsg.message + ' from ' + chatDataMsg.username + '</li>')
  }
  // below is the message I am sending
  socket.onopen = function(e) {
    console.log("open", e)
    formData.submit(function(event) {
      event.preventDefault()
      var msgText = msgInput.val()

      var finalData = {
        'message': msgText
      }
      socket.send(JSON.stringify(finalData))
      formData[0].reset()
    })
  }
  socket.onerror = function(e) {
    console.log("error", e)
  }
  socket.onclose = function(e) {
    console.log("close", e)
  }
</script>

<script>
  document.addEventListener('DOMContentLoaded', function() {
    const webSocketBridge = new channels.WebSocketBridge();
    webSocketBridge.connect('/ws');
    webSocketBridge.listen(function(action, stream) {
      console.log("RESPONSE:", action);
    })
    document.ws = webSocketBridge; /* for debugging */
  })
</script>

{% endblock %}
Trilla
  • 943
  • 2
  • 15
  • 31
  • hi i'm partial reproduced your code and will be ready to start search the solution about two hours later. – Brown Bear Apr 06 '19 at 09:04
  • do you use the [tutorial](https://channels.readthedocs.io/en/latest/tutorial/index.html)? – Brown Bear Apr 07 '19 at 08:16
  • @BearBrown Hi, I've just seen this now. Yes I have read the tutorial but it is only for chat service, not for notifications of new messages, – Trilla Apr 08 '19 at 07:17
  • good, i will write my think about the notification, as i can see it) – Brown Bear Apr 08 '19 at 07:43
  • i created the example witn notification here start main part of the code [room.html#L19](https://github.com/gitavk/djch/blob/master/chat/templates/chat/room.html#L19). The consumer is not full version of the your `ChatConsumer` but hope it help you. Main idea that the notification show on the front so the handler released on the front. – Brown Bear Apr 08 '19 at 09:25
  • 1
    Okay I will look at it now!! Thank you. – Trilla Apr 08 '19 at 13:01
  • 1
    @BearBrown I've updated my code above to show the `thread.html` which is basically like your `room.html` – Trilla Apr 09 '19 at 11:00
  • hi i was waiting in the SO chat, but looks like the notifiction here not work like i think about) glad you find solution! – Brown Bear Apr 16 '19 at 07:29
  • I can't find the chat link anymore, I was waiting in there yesterday – Trilla Apr 16 '19 at 08:49
  • @Trilla... Hello, I am using same codes in my project, I am having a problem on how to append timestamp to message. How were you able to do that? I need help. – MrHize Jul 08 '20 at 11:16
  • You mean bring the timestamp up on the frontend? @MrHize – Trilla Jul 23 '20 at 15:23
  • @Trilla...thanks for your response, I was able to display the timestamp by using jquery and append it to html. I am also having a problem on how I can pass the sender profile picture when message is sent. For example, if user A send message to user B, user B will be able to see the profile picture of user A along side with user A message. Just like Facebook and Instagram – MrHize Jul 23 '20 at 20:00

2 Answers2

13

One easy way to implement a notification system can be:

When you want to show a new message, manipulate HTML using JS as soon as you get a message on the websocket. And whenever the element has been interacted with, which means the user has read the notification, send a message back to server using the websocket.

Your Notification can have ForeignKeys to user and the message along with a BooleanField for read status. Whenever you are sending the message to the user, you should append the notification_id along the message,

#consumer.py
async def websocket_receive(self, event):
        # when a message is received from the websocket
        print("receive", event)

        message_type = event.get('type', None)  #check message type, act accordingly
        if message_type == "notification_read":
             # Update the notification read status flag in Notification model.
             notification = Notification.object.get(id=notification_id)
             notification.notification_read = True
             notification.save()  #commit to DB
             print("notification read")

        front_text = event.get('text', None)
        if front_text is not None:
            loaded_dict_data = json.loads(front_text)
            msg =  loaded_dict_data.get('message')
            user = self.scope['user']
            username = 'default'
            if user.is_authenticated:
                username = user.username
            myResponse = {
                'message': msg,
                'username': username,
                'notification': notification_id  # send a unique identifier for the notification
            }
            ...

On the client side,

// thread.html
socket.onmessage = function(e) {
    var data = JSON.parse(event.data);
    // Find the notification icon/button/whatever and show a red dot, add the notification_id to element as id or data attribute.
}
...

$(#notification-element).on("click", function(){
    data = {"type":"notification_read", "username": username, "notification_id": notification_id};
    socket.send(JSON.stringify(data));
});

You can mark individual/all unread notifications as read according to your need.

I did something similar for a training project, you can check that out for ideas. Github link.

Aman Garg
  • 2,507
  • 1
  • 11
  • 21
  • I've just glanced over this briefly but this looks really great, thank you so much, I'm going to try it now. – Trilla Apr 15 '19 at 15:02
  • Okay so I've updated my code accordingly. A few questions if you don't mind. I get a syntax error with `if message_type = "notification_read":` but changing it to `if message_type == "notification_read":` fixes it. Secondly, should I be adding `id=notification_id` to the inbox icon on the navbar? Would you mind updating the code to show how I would `#Update the notification read status flag` would it be `notification_read = True`? – Trilla Apr 15 '19 at 18:22
  • Haha I am editing my original code at the exact same time to show you what I've changed! – Trilla Apr 15 '19 at 18:29
  • Syntax error was my bad. Updated the code to show how `Notification` is updated. `id=notification_id ` should be added to the element that corresponds to notification, like when you click on a bell icon and you see all the notifications. It is basically to let the app know which exact notification the user interacted with. – Aman Garg Apr 15 '19 at 18:31
  • Okay so, in this case, it would be the inbox icon on the navbar. Will I have to implement some kind of js so that clicking on that icon displays the new message without leaving the current page? Like stack overflow? Right now it takes the user to their inbox but should bring up the new unread message `.on("click")` and the clicking on THAT should take them to the actual thread. – Trilla Apr 15 '19 at 18:34
  • yeah..the communication between client and server is not related to page elements. You need to handle what to do with the messages(notifications) received from the server. It should be straightforward though, all you need to do is create the required elements based on the notification and insert at the appropriate place. If you have jquery, you can use functions like [append](https://api.jquery.com/add/) – Aman Garg Apr 15 '19 at 18:42
  • Would you mind looking over the question I just posted? It's based on this and I have a question about getting the unread messages to display in the popout notification. https://stackoverflow.com/questions/55774025/django-templating-language-syntax-for-navbar-notifications – Trilla Apr 20 '19 at 15:35
  • It's me again! One more question, this line `'notification': notification_id` throws the error `name 'notification_id' is not defined` ...what should it be defined as? – Trilla Apr 26 '19 at 19:36
  • `notification_id` is the identifier of the notification, which you can put in `id` or a `data` attribute of the notification element when you create one. – Aman Garg Apr 27 '19 at 08:22
0

I couldn't mark this as a duplicate, because there is a bounty on it. But the solution is, you need more than two models. According to this post, your models.py should look something like this:

class MessageThread(models.Model):
    title = models.CharField()
    clients = models.ManyToManyField(User, blank=True)

class Message(models.Model):
    date = models.DateField()
    text = models.CharField()
    thread = models.ForeignKey('messaging.MessageThread', on_delete=models.CASCADE)
    sender = models.ForeignKey(User, on_delete=models.SET_NULL)

Your consumers.py should look like this:

class ChatConsumer(WebSocketConsumer):
    def connect(self):
        if self.scope['user'].is_authenticated:
            self.accept()
            # add connection to existing groups
            for thread in MessageThread.objects.filter(clients=self.scope['user']).values('id'):
                async_to_sync(self.channel_layer.group_add)(thread.id, self.channel_name)
            # store client channel name in the user session
            self.scope['session']['channel_name'] = self.channel_name
            self.scope['session'].save()

    def disconnect(self, close_code):
        # remove channel name from session
        if self.scope['user'].is_authenticated:
            if 'channel_name' in self.scope['session']:
                del self.scope['session']['channel_name']
                self.scope['session'].save()
            async_to_sync(self.channel_layer.group_discard)(self.scope['user'].id, self.channel_name)
xilpex
  • 3,097
  • 2
  • 14
  • 45