Experimenting with Django Channels and apostello

28 Jul 2016

Django Channels adds a new layer to Django to enable things like websockets, HTTP/2 and background tasks.

I wanted to play around with it, so I thought I would have a go at adding it to apostello.

We are going to add desktop notifications to apostello everytime someone sends us an SMS.

A diff of all the changes we need to make can be found here.

Install and setup channels

These steps are taken from the channels documentation.

Install channels and asgi_redis:

pip install -U channels asgi_redis

Add it to your settings.py, or in the case of apostello settings/common.py

# add to installed apps
INSTALLED_APPS = [
    ...
    'channels',
    ...
]
# config channels
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "asgi_redis.RedisChannelLayer",
        "ROUTING": "apostello.routing.channel_routing",
        "CONFIG": {
            "hosts": [os.environ.get('REDIS_URL', 'redis://localhost:6379'), ],
        },
     },
 }

Now we need to set up our routes in apostello/routing.py:

from channels.routing import route
from apostello import consumers

channel_routing = [
    route("websocket.connect", consumers.ws_connect),
    route("websocket.disconnect", consumers.ws_disconnect),
]

And our consumers in apostello.consumers.py:

import json
from channels import Group

def ws_connect(message):
	Group('sms_notification').add(message.reply_channel)

def ws_disconnect(message):
	Group('sms_notification').discard(message.reply_channel)

These consumers are very simple: when any websocket connection is opened, it is added to the group sms_notification.

We want to send a message when a new SMS is received, so we will add a method on the SmsInbound model:

# in apostello/models.py
import json
from channels import Group
...
class SmsInbound(models.Model):
...
def send_notification(self):
    """Sends a notification on SMS arrival"""
    notification = {
        'sender_name': self.sender_name,
        'content': self.content,
          'time_received': str(self.time_received),
    }
    Group('sms_notification').send({'text': json.dumps(notification)})
...

What is going on here? We create a dictionary with the information we want to send to the client, then we send it as JSON to the sms_notification Group that we used in the consumers.

Now anytime we call `sms.send_notication()’ we will push a message to all connected clients.

We need to send a message when we create a new SmsInbound instance, we could do this with Django signals or by overriding the save method on the model. However, in apostello we can just call send_notification in the apostello.tasks.log_msg_in task.

# in apostello/tasks.py
...
def log_msg_in(p, t, from_pk):
    """Log incoming message."""
    from apostello.models import Keyword, SmsInbound, Recipient
    from_ = Recipient.objects.get(pk=from_pk)
    matched_keyword = Keyword.match(p['Body'].strip())
    sms = SmsInbound.objects.create(
        sid=p['MessageSid'],
        content=p['Body'],
        time_received=t,
        sender_name=str(from_),
        sender_num=p['From'],
        matched_keyword=str(matched_keyword),
        matched_link=Keyword.get_log_link(matched_keyword),
        matched_colour=Keyword.lookup_colour(p['Body'].strip())
    )
    sms.send_notification()
	# check log is consistent:
    async('apostello.tasks.check_incoming_log')
...

Now we need to run the server with channels. The documentation says that we should be able to use Django’s runserver command, but I couldn’t get that to work, so we will run the server more like we do in production.

There are three components: an interface server (daphne, this replaces gunicorn or wsgi), a worker and a backend (we will use redis). More details and options can be found here.

We need to create an asgi.py file beside our wsgi.py:

# in apostello/asgi.py
import os
import channels.asgi

from apostello.loaddotenv import loaddotenv
if os.environ.get('DYNO_RAM') is None:
    loaddotenv()

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev")
channel_layer = channels.asgi.get_channel_layer()

Now we can start the interface server with (make sure you have redis running):

daphne apostello.asgi:channel_layer --port 8888

If you go to localhost:8888, you won’t see anything as there are no workers.

Start a worker in another terminal:

./manage.py runworker

Now you should be able to see apostello if you refresh your browser. With this setup you can run as many workers as you like to scale your application.

Add notifications to the frontend

Now that our backend is setup, we need to open a websocket to connect to our server:

// in apostello/assets/js/main.js
import { renderNotifications } from './render_notifications';
...
function appInit() {
...
	// render notifications
	renderNotifications();
...
}
...
// in apostello/assets/js/render_notifications.js
import WebSocket from 'reconnecting-websocket';
import biu from 'biu.js';

function addNotification(data) {
  const result = JSON.parse(data);
  if (window.Notification && Notification.permission === 'granted') {
    const body = `New message: ${result.content}\nFrom: ${result.sender_name}`;
    const note = new Notification('apostello', { body });
    note.onclick = () => {
      document.location = '/';
    };
  } else {
    biu(
      `${result.sender_name}: ${result.content}`,
      { type: 'success', timeout: 5000 }
    );
  }
}

window.addEventListener('load', () => {
  Notification.requestPermission((status) => {
    if (Notification.permission !== status) {
      Notification.permission = status;
    }
  });
});

function renderNotifications() {
  const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
  const wsUrl = `${wsScheme}://${window.location.host}/`;
  const ws = new WebSocket(wsUrl);
  ws.addEventListener('message', (message) => {
    addNotification(message.data);
  });
}

export { renderNotifications };

So what is happening here? First we ask the user for permission to show desktop notifications when the page loads. Then we open a reconnecting websocket and create a new notification everytime the server sends us a message.

Note that we fallback to biu (a alert replacement) if we do not have permission to show desktop notifications.

What it looks like

What it looks like

Things to improve

  • Any user can join the group and receive notifications - we should update the consumers to respect the UserProfile permissions.
  • A user must have apostello open to receive updates - a service worker could be used to notify a user even when they are not on the site.

Wrap up and release?

Channels makes it super easy to work with websockets in Django. This (very) small example was intuitive and fun to implement. You should definitely try channels!

So will these notifications be in the next release of apostello?

Unfortunately, no. apostello can currently be deployed for free on Heroku (the free tier permits two processes - a web process and a worker process). But apostello with channels would currently require 3 processes:

  • Daphne
  • Channels worker
  • Django Q worker (Django Q is used for background tasks)

Channels can be used for background tasks, however, I feel Django Q is currently a better solution in this space (tracking tasks, periodic tasks, etc) and so channels would have to run alongside it. Maybe that will change in the future…

I think enabling new users to get started for free on Heroku is much more important than desktop notifications right now.

All the code can be found on the channels branch of apostello on Github.

(Note that none of deployment options have been updated on this branch to work with channels, so don’t try to deploy this branch to production.)