django-channels-presence¶
django-channels-presence
is a Django app which adds “rooms” and presence
notification capability to a Django application using django-channels. If you’re building a chat room
or other site that needs to keep track of “who is connected right now”, this
might be useful to you.
Quick install¶
Install with pip:
pip install django-channels-presence
Add
"channels_presence"
toINSTALLED_APPS
:# proj/settings.py INSTALLED_APPS = [ ... "channels_presence", ... ]
Upgrading¶
Django Channels v2 is a major backwards-incompatible change from Django Channels v1; and hence the latest django-channels-presence is not compatible with older versions of django-channels.
If you’re using:
- django-channels v1.x: Use django-channels-presence v0.
- django-channels v2.x: Use django-channels-presence v1+.
Code that uses django-channels-presence
v0 or django-channels
v1 will
need to be rewritten to target channel layers, consumers, and other new
concepts from django-channels
v2.
Motivation¶
This application builds on top of
django-channels. You should have a good
understanding of how it works before diving in here. To enable groups and
messaging by channel name, django-channels-presence
requires that the
optional “channel layers” feature of django-channels
v2 be used.
There are 3 main tasks that need to be accomplished in order to track “presence” in rooms using django-channels
:
- Observe connections, adding the channel names for each connecting socket to a group.
- Observe disconnections, removing the channel names for each connecting socket from a group.
- Prune channel names from groups after they go stale. We won’t always get a socket disconnect event when a socket drops off; so we need to use heartbeats and a periodic pruning task to remove stale connections.
django-channels-presence
provides database models and helpers to handle
each of these tasks. This implementation makes database queries on every
connection, disconnection, and message, as well as periodic queries to prune
stale connections. As a result, it will scale differently than
django-channels
alone.
Contents:
Usage¶
Prerequisites¶
Install and set up django-channels and channel layers. A
CHANNEL_LAYERS
configuration is necessary to enable the use of consumer
channel_name
properties, to allow storing groups of channels by name.
Managing presences¶
In django-channels-presence
, two main models track the presence of channels in a room:
Room
: represents a collection of channels that are in the same “room”. It has a single property,channel_name
, which is the “group name” for the channel layer group to which its members are added.Presence
: represents an association of a single consumer channel name with aRoom
, as well as the associated authUser
if any.
To keep track of presence, the following steps need to be taken:
- Add channels to a
Room
when users successfully join. - Remove channels from the
Room
when users leave or disconnect. - Periodically update the
last_seen
timestamp for clients’Presence
. - Prune stale
Presence
records that have old timestamps by running periodic tasks. - Listen for changes in presence to update application state or notify other users
1. Adding channels to rooms¶
Add a user to a Room
using the manager add
method. For example, this
consumer adds the connecting user to a room on connection. This will trigger the channels_presence.signals.presence_changed signal:
from channels.generic.websocket import WebsocketConsumer
from channels_presence.models import Room
class MyConsumer(WebsocketConsumer):
def connect(self):
super().connect()
Room.objects.add("some_room", self.channel_name, self.scope["user"])
Channel names could be added to a room at any stage – for example, in the
connect
handler, the receive
handler, or
wherever else makes sense. In addition to handling Room
and Presence
models, Room.objects.add
takes care of adding the channel name to the named
channel layer group.
2. Removing channels from rooms¶
Remove a consumer’s channel from a Room using the manager remove
method.
For example, this handler for disconnect
removes the user from the room on
disconnect. This will trigger the presence_changed
signal:
from channels.generic.websocket import WebsocketConsumer
from channels_presence.models import Room
class MyConsumer(WebsocketConsumer):
def disconnect(self, close_code):
Room.objects.remove("some_room", self.channel_name)
Room.objects.remove
takes care of removing the specified channel name from
the channels group.
For convenience, channels_presence.decorators.remove_presence
is a decorator to accomplish the same thing:
from channels.generic.websocket import WebsocketConsumer
from channels_presence.decorators import remove_presence
class MyConsumer(WebsocketConsumer):
@remove_presence
def disconnect(self, close_code):
pass
3. Updating the “last_seen” timestamp¶
In order to keep track of which sockets are actually still connected, we must
regularly update the last_seen
timestamp for all present connections, and
periodically remove connections from rooms if they haven’t been seen in a
while.
from channels.generic.websocket import WebsocketConsumer
from channels_presence.models import Presence
class MyConsumer(WebsocketConsumer):
def receive(self, close_code):
Presence.objects.touch(self.channel_name)
For convenience, the channels_presence.decorators.touch_presence
decorator accomplishes the same thing:
from channels.generic.websocket import WebsocketConsumer
from channels_presence.decorators import touch_presence
# handler for "websocket.receive"
class MyConsumer(WebsocketConsumer):
@touch_presence
def receive(self, text_data=None, bytes_data=None):
...
This will update the last_seen
timestamp any time any message is received
from the client. To ensure that the timestamp remains current, clients should
send a periodic “heartbeat” message if they aren’t otherwise sending data but
should be considered to still be present.
3a. Heartbeats¶
To allow efficient updates, if a client sends a message which is just the JSON
encoded string "heartbeat"
, the touch_presence
decorator will stop
processing of the message after updating the timestamp. The decorator can be
placed first in a decorator chain in order to stop processing of heartbeat
messages prior to other costly steps.
If updating last_seen
on every message is too costly, an alternative to
using the touch_presence
decorator is to manually call
Presence.objects.touch
whenever desired. For example, this updates
last_seen
only when the literal message "heartbeat"
is received:
from channels.generic.websocket import WebsocketConsumer
from channels_presence.models import Presence
class MyConsumer(WebsocketConsumer):
def receive(self, text_data=None, bytes_data=None):
...
if text_data == '"heartbeat"':
Presence.objects.touch(self.channel_name)
To ensure that an active connection is not marked as stale, clients should
occasionally send "heartbeat"
messages:
// client.js
setInterval(function() {
socket.send(JSON.stringify("heartbeat"));
}, 30000);
The frequency should be adjusted to occur before the maximum age for
last-seen presence, set with settings.CHANNELS_PRESENCE_MAX_AGE
(default 60
seconds).
4. Pruning stale connections¶
In order to remove connections whose timestamps have expired, we need to
periodically launch a cleaning task. This can be accomplished with
Room.objects.prune_presences()
. For convenience, this is implemented as a
celery task which can be called with celery beat:
channels_presence.tasks.prune_presences
. The management command
./manage.py prune_presences
is also available for calling from cron.
A second maintenance command, Room.objects.prune_rooms()
, removes any Room
models that have no connections. This is also available as the celery task
channels_presence.tasks.prune_rooms
and management command
./manage.py prune_rooms
.
See the documentation for periodic tasks in celery details on configuring celery beat with Django. Here is one example:
# settings.py
CELERYBEAT_SCHEDULE = {
'prune-presence': {
'task': 'channels_presence.tasks.prune_presences',
'schedule': timedelta(seconds=60)
},
'prune-rooms': {
'task': 'channels_presence.tasks.prune_rooms',
'schedule': timedelta(seconds=600)
}
}
5. Listening for changes in presence¶
Use the channels_presence.signals.presence_changed
signal to be notified when
a user is added or removed from a Room. This is a useful place to define logic
to update other connected clients with the list of present users. See the
API reference for presence_changed for an example.
API Reference¶
Settings¶
CHANNELS_PRESENCE_MAX_AGE
- Default
60
. Maximum age in seconds before a presence is considered expired.
Models¶
Room
¶
from channels_presence.models import Room
Manager:
Room.objects.add(room_chanel_name, user_channel_name, user=None)
- Add the given
user_channel_name
(e.g.consumer.channel_name
) to a Room with the nameroom_channel_name
. If provided, associate the authUser
as well. Creates a newRoom
instance if it doesn’t exist; creates any neededPresence
instance, and updates the channels group membership. Returns theroom
instance. Room.objects.remove(room_channel_name, user_channel_name)
- Remove the given
user_channel_name
from the room withroom_channel_name
. Removes relevantPresence
instances, and updates the channels group membership. Room.objects.prune_presences(age_in_seconds=None)
- Remove any
Presence
models whoselast_seen
timestamp is older thanage_in_seconds
(defaults tosettings.CHANNELS_PRESENCE_MAX_AGE
if not specified). Room.objects.prune_rooms()
- Remove any rooms that have no associated
Presence
models.
Instance properties:
room.channel_name
- The channel name associated with the group for this room.
Instance methods:
room.get_users()
- Return a queryset with all of the unique authenticated users who are present in this room.
room.get_anonymous_count()
- Return the number of non-authenticated sockets which are present in this room.
Presence
¶
from channels_presence.models import Presence
Manager:
Presence.objects.touch(channel_name)
- Updates the
last_seen
timestamp to now for all instances with the given channel name. Presence.objects.leave_all(channel_name)
- Removes all
Presence
instances with the given channel name. Triggerschannels_presence.signals.presence_changed
for any changed rooms.
Instance properties:
presence.room
- The room to which this Presence belongs
presence.channel_name
- The consumer channel name associated with this Presence
presence.user
- A
settings.AUTH_USER_MODEL
associated with this Presence, or None presence.last_seen
- Timestamp for the last time socket traffic was seen for this presence.
Decorators¶
touch_presence
¶
from chanels_presence.decorators import touch_presence
Decorator for use on websocket.receive
handlers which updates the
last_seen
timestamp on any Presence
instances associated with the
client. If the message being sent is the literal JSON-encoded "heartbeat"
,
message processing stops and the decorator does not call the decorated
function. Note that this decorator is syncronous, so should only be used on
syncronous handlers.
from channels.generic.websocket import WebsocketConsumer
class MyConsumer(WebsocketConsumer):
@touch_presence
def receive(self, text_data=None, bytes_data=None):
pass
remove_presence
¶
from chanels_presence.decorators import remove_presence
Decorator for use on websocket.disconnect
handlers which removes any
Presence
instances associated with the client. Note that this decorator is
syncronous, so should only be used on syncronous handlers.
from channels.generic.websocket import WebsocketConsumer
class MyConsumer(WebsocketConsumer):
@remove_presence
def disconnect(self, close_code):
pass
Signals¶
presence_changed
¶
from channels_presence.signals import presence_changed
A Django signal dispatched on any addition or removal of a Presence
from a
Room
. Use it to track when users come and go.
Arguments sent with this signal:
room
- The
Room
instance from which aPresence
was added or removed. added
- The
Presence
instance which was added, orNone
. removed
- The
Presence
instance which was removed, orNone
. bulk_change
- If
True
, indicates that this was a bulk change in presence. More than one presence may have been added or removed, and particular instances will not be provided inadded
orremoved
arguments.
Example:
# app/signals.py
import json
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from channels_presence.signals import presence_changed
from django.dispatch import receiver
channel_layer = get_channel_layer()
@receiver(presence_changed)
def broadcast_presence(sender, room, **kwargs):
"""
Broadcast the new list of present users to the room.
"""
message = {
"type": "presence",
"payload": {
"channel_name": room.channel_name,
"members": [user.serialize() for user in room.get_users()],
"lurkers": room.get_anonymous_count(),
}
}
# Prepare a dict for use as a channel layer message. Here, we're using
# the type "forward.message", which will magically dispatch to the
# channel consumer as a call to the `forward_message` method.
channel_layer_message = {
"type": "forward.message",
"message": json.dumps(message)
}
async_to_sync(channel_layer.group_send)(room.channel_name, channel_layer_message)
# app/channels.py: App consumer definition
from channels.generic.websocket import WebsocketConsumer
class AppConsumer(WebsocketConsumer):
def forward_message(self, event):
"""
Utility handler for messages to be broadcasted to groups. Will be
called from channel layer messages with `"type": "forward.message"`.
"""
self.send(event["message"])