Tethys Platform
Table Of Contents
Table Of Contents

WebSockets Concepts

Last Updated: October 2019

This tutorial introduces WebSocket communication concepts for Tethys developers. A consumer will be created to notify other users when a dam has been added to the app database by someone else, giving the user the option to reload the app to visualize the location of the new dam. The topics covered include:

  • Django Channels Consumers

  • WebSocket Connections

  • Channel Layers

  • New Dam Notification

0. Start From Advanced Solution (Optional)

If you wish to use the advanced solution as a starting point:

git clone https://github.com/tethysplatform/tethysapp-dam_inventory.git
cd tethysapp-dam_inventory
git checkout -b advanced-solution advanced-3.2

Note

This tutorial can also be built on any other advanced solution, such as the Tethys Quotas tutorial solution.

1. Django Channels Consumers

Consumer classes are the equivalent of controller functions when working with WebSockets on Tethys.

  1. Create a new file called consumers.py and add the following code:

from channels.generic.websocket import AsyncWebsocketConsumer


class NotificationsConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        await self.accept()
        print("-----------WebSocket Connected-----------")

    async def disconnect(self, close_code):
        pass
  1. Create a UrlMap for the consumer in app.py by adding the following code.

UrlMap(
    name='dam_notification',
    url='dam-inventory/dams/notifications',
    controller='dam_inventory.consumers.NotificationsConsumer',
    protocol='websocket'
),

Note

The controller parameter of the UrlMap is pointing to the consumer added in the previous step. A new protocol parameter with a string value equal to websocket has been added to the UrlMap.

2. WebSocket Connections

A handshake needs to be established between the client and server when creating a WebSocket connection. We will use the standard JavaScript WebSocket API to do this.

Create a WebSocket connection by adding the following code to the home.html template after the app_content block.

...

{% block app_content %}
  {% gizmo dam_inventory_map %}
  <div id="popup"></div>
{% endblock %}

{% block after_app_content %}
  <script>
    var notification_ws = new WebSocket('ws://' + window.location.host + '/dam-inventory/dams/notifications/ws/');
  </script>
{% endblock %}

...

A WebSocket URL follows a pattern similar to tethys app HTTP URLs. The differences being that the URL starts with ws:// instead of http(s)://, and instead of the "apps" part at the beginning of the URL pattern, the URL ends with a "ws". For example: ws://tethys.host.com/base-app-name/base-ws-url/ws/. If the base name of the app is included in the WebSocket URL, it will not be duplicated. This is the same behavior for HTTP URLs.

Upon loading the app home page, the "WebSocket Connected" message will be printed to the terminal. The WebSocket connection can also be accessed from the browser by right-clicking and selecting inspect, network and filtering by "WS" as displayed in the image below.

../_images/ws-conn-browser.png

3. Channel Layers

A channel layer is needed for two or more app instances to communicate between each other (e.g. two different users interacting with the same app at the same time). A channel layer provides a backend where WebSocket messages can be stored and then accessed by the different app instances. The updated consumer in this step opens a communication link (channel_name) in the "notification" channel group on connect, and closes it on disconnect. A new async function has also been added to handle messages.

  1. Update the consumer class to look like this.

...

import json

...

class NotificationsConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        await self.accept()
        await self.channel_layer.group_add("notifications", self.channel_name)
        print(f"Added {self.channel_name} channel to notifications")

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard("notifications", self.channel_name)
        print(f"Removed {self.channel_name} channel from notifications")

    async def dam_notifications(self, event):
        message = event['message']
        await self.send(text_data=json.dumps({'message': message}))
        print(f"Got message {event} at {self.channel_name}")

The respective print messages set on connect and disconnect will appear in the terminal when the app home is opened or closed.

  1. Channel layers require a backend to store the WebSocket messages coming from different app instances. These messages can be stored in memory. Add the following peace of code to the portal_config.yml file.

settings:
  CHANNEL_LAYERS:
    default:
      BACKEND: channels.layers.InMemoryChannelLayer

Note

Django Channels recommends the use of an external backend store for production environments. The channels-redis python package plus Redis Server are the default recommendation. For more information see Django Channels channel layers and deploying sections.

Tip

A Channel layer can be added to the settings section of the portal_config.yml by manually editing the file or by running tethys settings --set CHANNEL_LAYERS.default.BACKEND <<CHANNEL_LAYERS_BACKEND>> where <<CHANNEL_LAYERS_BACKEND>> is the python dot-formatted path of the channel layer. See Tethys Portal Configuration for details.

Channel Layer Definitions

Term

Simplified definition

channel name

Communication link unique to an app instance.

channel group

Communication link for different app instances to talk to each other.

channel layer

The mechanism that enables communication between different app instances.

channel layer backend

A backend database to store group messages.

4. New Dam Notification

Now that we have a working WebSocket connection and a communication backend is set, let's add the programming logic.

  1. Add the following code to the add_dam controller in controllers.py.

...

from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

...

def add_dam(request):

...

    new_num_dams = session.query(Dam).count()

    if new_num_dams > num_dams:
        channel_layer = get_channel_layer()
        async_to_sync(channel_layer.group_send)(
            "notifications", {
                "type": "dam_notifications",
                "message": "New Dam"
            }
        )

    return redirect(reverse('dam_inventory:home'))

messages.error(request, "Please fix errors.")

This piece of code checks to see if a new dam has been added and if so it sends a message to the notification group. Notice that the type of the group message is dam_notifications.

Note

Channel layers can easily be accessed from within a consumer by calling self.channel_layer. From outside the consumer they can be called with channels.layers.get_channel_layer.

Note

Channel layers are purely asynchronous so they need to be wrapped in a converter like async_to_sync to be used from synchronous code.

  1. Let's create a message box to display our notification when a new app is added. Add the following code to the home controller in controllers.py.

from tethys_sdk.gizmos import MessageBox

...

def home(request):

...

    message_box = MessageBox(
        name='notification',
        title='',
        dismiss_button='Nevermind',
        affirmative_button='Refresh',
        affirmative_attributes='onClick=window.location.href=window.location.href;'
    )

    context = {
        'dam_inventory_map': dam_inventory_map,
        'message_box': message_box,
        'add_dam_button': add_dam_button,
        'can_add_dams': has_permission(request, 'add_dams')
    }

    return render(request, 'dam_inventory/home.html', context)

...

This gizmo creates an empty message box with a current page refresh. It will be populated in the next step based on our WebSocket connection.

  1. Now that the logic has been added, lets add the tethys message box gizmo and modify the WebSocket connection to listen for any New Dam messages and populate our message box accordingly. Update the code in home.html as follows.

...

{% block app_content %}
  {% gizmo dam_inventory_map %}
  <div id="popup"></div>
{% endblock %}

{% block after_app_content %}
{% gizmo message_box %}
  <script>
    var notification_ws = new WebSocket('ws://' + window.location.host + '/dam-inventory/dams/notifications/ws/');
    var n_div = $("#notification");
    var n_title = $("#notificationLabel");
    var n_content = $('#notification .lead');

    notification_ws.onmessage = function (e) {
      var data = JSON.parse(e.data);
      if (data["message"] = "New Dam") {
        n_title.html('Dam Notification');
        n_content.html('A new dam has been added. Refresh this page to load it.');
        n_div.modal();
      }
    };
  </script>
{% endblock %}

Besides the message_box gizmo, a simple JavaScript conditional has been added to display and populate the message box if the message our WebSocket connection listened for is equal to New Dam.

Test the WebSocket communication by opening two instances of the dam inventory app at the same time. Add a dam in one instance, a message box will display on the home of the other instance suggesting a refresh to display the newly added dam.

Note

Other WebSockets could be added to the app as a way of practice. For example: another message box when a hydrograph has been added to a dam.

5. Solution

This concludes the WebSockets tutorial. You can view the solution on GitHub at https://github.com/tethysplatform/tethysapp-dam_inventory or clone it as follows:

git clone https://github.com/tethysplatform/tethysapp-dam_inventory.git
cd tethysapp-dam_inventory
git checkout -b websocket-solution websocket-3.2