Search code examples
djangovue.jswebsocketchanneluvicorn

Problems with Django Channels and signals


I have Vue script that retrieve notifications from Django and should display and update the notification count in a badge. I use Django channels, the Django version is 4.2.8, channels version 4, uvicorn 0.26 and websockets 12.0. To update the notification count I use a Django signal so when a new notification is added from the admin or another source, the post_save event gets triggered and the method update_notification_count in consumers.py should be called. Everything works fine from the browser, but when I add a new notification from the Django admin, the frontend does not get updated, that is, the update_notification_count is not called even if the event post_save gets triggered. Here is the code.

First my config:

# settings.py
CHANNEL_LAYERS = {
        'default': {
        'BACKEND': 'channels.layers.InMemoryChannelLayer',
        'CONFIG': {
            'capacity': 1000,
            'expiry': 60,
        },
    }
}

Now asgi.py

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from notifapi.routing import websocket_urlpatterns

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

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": URLRouter(websocket_urlpatterns),
})

The signals.py file is coded that way

from django.dispatch import receiver
from django.db.models.signals import post_save
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from .models import NotifyModel as Notification
from .consumers import NotificationConsumer


@receiver(post_save, sender=Notification)
def notification_created(sender, instance, created, **kwargs):
    try:
        if created:
            print(f"A new notification was created {instance.message}")
            channel_layer = get_channel_layer()
            async_to_sync(channel_layer.group_send)(
                "public_room",
                {
                    "type": "update_notification_count",
                    "message": instance.message
                }
            )
    except Exception as e:
        print(f"Error in group_send: {e}")

Now consumers.py

from channels.generic.websocket import AsyncWebsocketConsumer
from django.apps import apps
from django.core.serializers import serialize
from asgiref.sync import sync_to_async
import json
import logging

class NotificationConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # Allow all connections
        await self.channel_layer.group_add("public_room", self.channel_name)
        await self.accept()
        await self.update_notification_count()

    
    async def update_notification_count(self, event=None):
        print("update_notification_count method called with event:", event)

        NotifyModel = apps.get_model('notifapi', 'NotifyModel')
        notifications = await sync_to_async(list)(NotifyModel.objects.all().values('is_read'))
        messages = await sync_to_async(list)(NotifyModel.objects.all().values('message'))
        # Get the notification count asynchronously using a custom utility method
        notification_count = len(notifications)
        
        
        #print(f"Notification count is {notification_count}")
        # Extracting is_read values from notifications
        is_read_values = [notification['is_read'] for notification in notifications]
        messages_values = [notification['message'] for notification in messages]
        #print("Am I here?")
        print(f"Messages values are: {messages_values}")
        await self.send(text_data=json.dumps({
            "type": "notification.update",
            "count": notification_count,
            "is_read_values": is_read_values,
            "messages_values": messages_values
        }))

        
    async def disconnect(self, close_code):
        print("Consumer disconnected")
        # Remove the channel from the public_room group when the WebSocket connection is closed
        await self.channel_layer.group_discard(
            "public_room",
            self.channel_name
        )


    async def receive(self, text_data):
        # Handle incoming messages (if any)
        data = json.loads(text_data)
        if data['type'] == 'update.notification.count':
            await self.update_notification_count()

And finally the Vue scripts (just for reference because the problem depends on the signals.py, not the Vue script):

// App.vue
<template>
  <div id="app">
    <div id="wrapper">
      <NotificationBell />
    </div>
  </div>
</template>

<script>
import NotificationBell from './components/NotificationBell.vue';

export default {
  components: {
    NotificationBell,
  },
};
</script>

And NotificationBell.vue

<template>
    <div class="fancy-container">
        <a href="#" class="position-relative">
            <i class="fa fa-bell _gray" style="font-size:24px"></i>
            <span class="my-text btnLnk">Visits</span>
            <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger _reduced">
                {{ notificationCount }}
            </span>
        </a>
    </div>
</template>
<script>
//import Axios from 'axios';

export default {
    data() {
        return {
            notifications: [],
            webSocket: null
        };
    },
    computed: {
        notificationCount() {
        // Calculate the notification count based on the current state of notifications
        return this.notifications.filter(notification => !notification.fields.is_read).length;
        }
    },
    mounted() {
        this.establishWebSocketConnection();
    },
    methods: {
        async establishWebSocketConnection() {
            this.webSocket = new WebSocket('ws://127.0.0.1:8001/websocket/ws/notifications/', 'echo-protocol');
            
            this.webSocket.onopen = () => {
                console.log('WebSocket connection established!');
                this.updateNotificationCount();
            };
            
            this.webSocket.onmessage = (event) => {
                console.log("Message received:", event.data);
                const message = JSON.parse(event.data);
                console.log("Received type:", message.type);  // Log the type field to identify the message type
                if(message.type === 'notification.update'){
                    this.notifications = message.is_read_values.map(is_read => ({ fields: { is_read } }));
                }else{
                    console.log("Notification count was not updated!")
                }

            };

            this.webSocket.onclose = () => {
                console.log('WebSocket connection closed.');
            // implement reconnect logic if desired
            };
        },
        updateNotificationCount() {
            console.log('updateNotificationCount called!');
            // Send a message to the WebSocket server to request updated notification count
            this.webSocket.send(JSON.stringify({
                "type": "update.notification.count"  // Define a custom type to trigger the count update
            }));

        },
    },
};
</script>

I perfectly know that all the model information could be retrieved by a single database hit in consumers.py and I will refactor it when I get the problem solved.

The problem is really that when I add a new notification from the Django admin, the print inside the notification_created function in signals.py is displayed

print(f"A new notification was created {instance.message}")

but the code here

async_to_sync(channel_layer.group_send)(
                "public_room",
                {
                    "type": "update_notification_count",
                    "message": instance.message
                }
            )

never gets called. I can see that in the gunicorn console and from my browser's console, only after I press F5 in the browser the information gets updated.

The signals has no influence and can't call the update_notification_count, I don't know why, maybe because signals have not been designed to call asynchronous methods in a consumers.py file, I don't know the reason at all, but I need a mechanism to push the new notifications when they are added so to get the count updated without having to refresh the browser.

I don't have experience with Django channels and web socket.

Anyone can help me with that?


Solution

  • Since nobody answered the question, I found the solution myself. The problem is that the conusmer run in a WSGI server or, at least, it runs on port 8000. But Uvicorn, which I used in development because I did not know that Daphne was the official solution for development (Daphne is no longer included with Django Channels since version 4), runs on port 8001. Different server and different port. When the code inside signals.py try to call the asynchoronous function in the consumers

    @receiver(post_save, sender=Notification)
    def notification_created(sender, instance, created, **kwargs):
        
        if created:
            print(f"A new notification was created {instance.message}")
            channel_layer = get_channel_layer()
            print(channel_layer)
            try:
                async_to_sync(channel_layer.group_send)(
                    'public_room',
                    {
                        "type":"update_notification_count",
                        "message":instance.message
                    }
                )
    

    even if it uses async_to_sinc, it is still unable to communicate with another server listening another port. It would only work by a direct call to the websocket from the client and for this reason it only worked on browsser refresh.

    This configuration

    CHANNEL_LAYERS = {
            'default': {
            'BACKEND': 'channels.layers.InMemoryChannelLayer',
            'CONFIG': {
                'capacity': 1000,
                'expiry': 60,
            },
        }
    }
    

    is only suitable when running in development mode with

    python manage.py runserver

    and using Daphne in development mode for both wsgi and asgi, which would run on the same port 8000 with Daphne. This would never work using Uvicorn like in production mode. InMemoryChannelLayer was not designed to deal with different servers on different ports. It was only disigned to be run using Daphne on the same port. But the documentation does not mention all these details, I had to figure all this myself.

    For this, I need Redis. Redis is not a channel layer. It is a key-value database that runs in memory. But the channels_redis.core.RedisChannelLayer channel layer, which must be installed with

    pip install channels-redis

    which will also install a dependency, redis, it's the one that would use Redis to make the comunication work and broke messages among different servers. In Windows I had to install Memurai, which is a Redis clone that runs on Windows, in order to test all this. And everything works perfectly now.

    So the lesson is:

    If you only want to use Channel Layers in development mode, especially if you are developing on Windows then use

    CHANNEL_LAYERS = {
            'default': {
            'BACKEND': 'channels.layers.InMemoryChannelLayer',
            'CONFIG': {
                'capacity': 1000,
                'expiry': 60,
            },
        }
    }
    

    Add this

    #WSGI_APPLICATION = 'myproj.wsgi.application'
    ASGI_APPLICATION = 'myproj.asgi.application'
    

    configure asgi.py accordingly and install Daphne to be run with the development server.

    If you want to run Django Channels in production or you want to test o simulate a production environment, even in Windows, then you must install Memurai and follow the same instructions described above.

    So I hope to help someone else with my solution. This is a very trickly problem when you don't know how to proceed, because even AI tools are completely unable to figure out a solution.