Channels time
We have our functional application now, so we’re moving on to working in Channels. First a tiny bit about how Channels works.
Key Channels Concepts:
- Routing: A routing file in Channels works a lot like the Django urls. It ties routes (like ‘/game/’) to consumers.
- Consumers: Consumers are similar to Django views. They handle the websocket calls just as a view class would handle get or posts.
- Channel: A channel is essentially the websocket connection between the server and an individual client.
- Group: A channel group is a named group of one or more channels. A two player game, like we’re creating, is a good example of how groups are useful. Each player has their own unique channel connection which is specific to them. When we build the game bit later, we’ll put both players’ channels into a single group that is named after their shared game of Obstruction. This allows for us to send and receive game updates to only those two users for their specific game. Not all channels will receive every game’s information.
That’s really about all there is with Channels at this basic level. It can be VERY simple to implement and, as designed, is extremely Django-y and familiar.
The Models
First, we’ll create the models to support the game. Replace everything in your models.py file with:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
from django.contrib.auth.models import User from django.db import models from channels import Group import json from datetime import datetime class Game(models.Model): """ Represents a game of Obstruction between two players. Initial values when created will just be a creator who is also the current_turn and the cols and rows """ creator = models.ForeignKey(User, related_name='creator') opponent = models.ForeignKey( User, related_name='opponent', null=True, blank=True) winner = models.ForeignKey( User, related_name='winner', null=True, blank=True) cols = models.IntegerField(default=6) rows = models.IntegerField(default=6) current_turn = models.ForeignKey(User, related_name='current_turn') # dates completed = models.DateTimeField(null=True, blank=True) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) def __unicode__(self): return 'Game #{0}'.format(self.pk) @staticmethod def get_available_games(): return Game.objects.filter(opponent=None, completed=None) @staticmethod def created_count(user): return Game.objects.filter(creator=user).count() @staticmethod def get_games_for_player(user): from django.db.models import Q return Game.objects.filter(Q(opponent=user) | Q(creator=user)) @staticmethod def get_by_id(id): try: return Game.objects.get(pk=id) except Game.DoesNotExist: # TODO: Handle this Exception pass @staticmethod def create_new(user): """ Create a new game and game squares :param user: the user that created the game :return: a new game object """ # make the game's name from the username and the number of # games they've created new_game = Game(creator=user, current_turn=user) new_game.save() # for each row, create the proper number of cells based on rows for row in range(new_game.rows): for col in range(new_game.cols): new_square = GameSquare( game=new_game, row=row, col=col ) new_square.save() # put first log into the GameLog new_game.add_log('Game created by {0}'.format(new_game.creator.username)) return new_game def add_log(self, text, user=None): """ Adds a text log associated with this game. """ entry = GameLog(game=self, text=text, player=user).save() return entry def get_all_game_squares(self): """ Gets all of the squares for this Game """ return GameSquare.objects.filter(game=self) def get_game_square(row, col): """ Gets a square for a game by it's row and col pos """ try: return GameSquare.objects.get(game=self, cols=col, rows=row) except GameSquare.DoesNotExist: return None def get_square_by_coords(self, coords): """ Retrieves the cell based on it's (x,y) or (row, col) """ try: square = GameSquare.objects.get(row=coords[1], col=coords[0], game=self) return square except GameSquare.DoesNotExist: # TODO: Handle exception for gamesquare return None def get_game_log(self): """ Gets the entire log for the game """ return GameLog.objects.filter(game=self) def send_game_update(self): """ Send the updated game information and squares to the game's channel group """ # imported here to avoid circular import from serializers import GameSquareSerializer, GameLogSerializer, GameSerializer squares = self.get_all_game_squares() square_serializer = GameSquareSerializer(squares, many=True) # get game log log = self.get_game_log() log_serializer = GameLogSerializer(log, many=True) game_serilizer = GameSerializer(self) message = {'game': game_serilizer.data, 'log': log_serializer.data, 'squares': square_serializer.data} game_group = 'game-{0}'.format(self.id) Group(game_group).send({'text': json.dumps(message)}) def next_player_turn(self): """ Sets the next player's turn """ self.current_turn = self.creator if self.current_turn != self.creator else self.opponent self.save() def mark_complete(self, winner): """ Sets a game to completed status and records the winner """ self.winner = winner self.completed = datetime.now() self.save() class GameSquare(models.Model): STATUS_TYPES = ( ('Free', 'Free'), ('Selected', 'Selected'), ('Surrounding', 'Surrounding') ) game = models.ForeignKey(Game) owner = models.ForeignKey(User, null=True, blank=True) status = models.CharField(choices=STATUS_TYPES, max_length=25, default='Free') row = models.IntegerField() col = models.IntegerField() # dates created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) def __unicode__(self): return '{0} - ({1}, {2})'.format(self.game, self.col, self.row) @staticmethod def get_by_id(id): try: return GameSquare.objects.get(pk=id) except GameSquare.DoesNotExist: # TODO: Handle exception for gamesquare return None def get_surrounding(self): """ Returns this square's surrounding neighbors that are still Free """ # TODO: # http://stackoverflow.com/questions/2373306/pythonic-and-efficient-way-of-finding-adjacent-cells-in-grid ajecency_matrix = [(i, j) for i in (-1, 0, 1) for j in (-1, 0, 1) if not (i == j == 0)] results = [] for dx, dy in ajecency_matrix: # boundaries check if 0 <= (self.col + dy) < self.game.cols and 0 <= self.row + dx < self.game.rows: # yield grid[x_coord + dx, y_coord + dy] results.append((self.col + dy, self.row + dx)) return results def claim(self, status_type, user): """ Claims the square for the user """ self.owner = user self.status = status_type self.save(update_fields=['status', 'owner']) # get surrounding squares and update them if they can be updated surrounding = self.get_surrounding() for coords in surrounding: # get square by coords square = self.game.get_square_by_coords(coords) if square and square.status == 'Free': square.status = 'Surrounding' square.owner = user square.save() # add log entry for move self.game.add_log('Square claimed at ({0}, {1}) by {2}' .format(self.col, self.row, self.owner.username)) # set the current turn for the other player if there are still free # squares to claim if self.game.get_all_game_squares().filter(status='Free'): self.game.next_player_turn() else: self.game.mark_complete(winner=user) # let the game know about the move and results self.game.send_game_update() class GameLog(models.Model): game = models.ForeignKey(Game) text = models.CharField(max_length=300) player = models.ForeignKey(User, null=True, blank=True) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) def __unicode__(self): return 'Game #{0} Log'.format(self.game.id) |
We’ve just added in 3 models (field names in purple):
- Game: Represents a game, obviously. A game mainly consists of-
- 2 players (creator and opponent),
- a winner,
- a current_turn tracker
- the number of cols/rows for the game grid.
- GameSquare: A GameSquare represents a single cell on the game board (cell would have been a better name than square, I suppose). When a game is created this table is filled with squares based on the number of the game’s cols and rows. Important fields here are:
- associated game object
- the owner is empty unless a user has claimed in the game
- a square’s status is “Free” to begin with, but can become either “Selected” or “Surrounding” based on whether it was directly chosen or is just next to a chosen field (this may make more sense later).
- the row and col hold the location of the square on the game board
- GameLog: Keeps track of game events and chat messages. It’s a simple a model with the key fields:
- the associated game
- the text of log holds the…well, the text of the log message
- player tracks which player sent a chat message. If null, it’s a system log message
Now, create the tables with migrations:
1 2 3 |
python manage.py makemigrations game python manage.py migrate game |
The Consumers
As I mentioned above, the consumers.py file is basically Channel’s views file. Create it now in your game app folder with the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import re import logging from channels import Group from channels.sessions import channel_session from .models import Game, GameSquare from channels.auth import channel_session_user from channels.generic.websockets import JsonWebsocketConsumer log = logging.getLogger(__name__) class LobbyConsumer(JsonWebsocketConsumer): # Set to True to automatically port users from HTTP cookies # (you don't need channel_session_user, this implies it) http_user = True def connection_groups(self, **kwargs): """ Called to return the list of groups to automatically add/remove this connection to/from. """ return ["lobby"] def connect(self, message, **kwargs): """ Perform things on connection start """ self.message.reply_channel.send({"accept": True}) pass def receive(self, content, **kwargs): """ Called when a message is received with either text or bytes filled out. """ http_user = True def disconnect(self, message, **kwargs): """ Perform things on connection close """ pass |
The above LobbyConsumer class will handle all calls that are sent to it through the routes we’re about to set up. The comments in the code above should provide some information on what happens there (and you can find more here in the official docs), but basically, all we’re using is the connection_groups and receive methods. I kept the two unused methods in the code with the comments from the docs to give a better perspective on how these classes work.
In connection_groups we set the group name for the connection. This allows us to use Group(‘lobby’).send() later to send data to channels that are connected to the lobby.
In receive we will act on the message sent through the websocket from the client side. At the moment we just set the http_user = True to ensure that we have the Django user in the channel message — message.user will act just as request.user does in a regular Django request.
Ok, so we have “view” set up for Lobby, so now we’ll add the “url” to connect it. Update your routing file with the highlighted lines:
1 2 3 4 5 6 7 8 9 |
from channels.routing import route, route_class from channels.staticfiles import StaticFilesConsumer from game import consumers # routes defined for channel calls # this is similar to the Django urls, but specifically for Channels channel_routing = [ route_class(consumers.LobbyConsumer, path=r"^/lobby/"), ] |
The channels-side for our lobby page is set up. It can now accept a websocket call through the route we created, which will then add the channel to the “Lobby” group. We’ll handle the message received from that call in a minute, but first we need page to go to.
Standard Django
At the bottom of your settings.py file, add:
1 2 |
LOGIN_REDIRECT_URL = '/lobby/' LOGIN_URL = '/login/' |
Now add the following highlighted line to your urls:
5 6 7 8 9 10 11 12 13 |
urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^register/', CreateUserView.as_view()), url(r'^login/$', login, {'template_name': 'login.html'}), url(r'^logout/$', logout, {'next_page': '/'}), url(r'^lobby/$', LobbyView.as_view()), url(r'^$', HomeView.as_view()) |
and the following highlighted class to your views:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
from django.views.generic import CreateView, TemplateView from django.contrib.auth.forms import UserCreationForm from django.contrib.auth import authenticate, login from game.models import User, Game from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator class HomeView(TemplateView): template_name = 'home.html' class CreateUserView(CreateView): template_name = 'register.html' form_class = UserCreationForm success_url = '/' def form_valid(self, form): valid = super(CreateUserView, self).form_valid(form) username, password = form.cleaned_data.get('username'), form.cleaned_data.get('password1') new_user = authenticate(username=username, password=password) login(self.request, new_user) return valid class LobbyView(TemplateView): template_name = 'components/lobby/lobby.html' @method_decorator(login_required) def dispatch(self, request, *args, **kwargs): return super(LobbyView, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super(LobbyView, self).get_context_data(**kwargs) # get current open games to prepopulate the list # we're creating a list of games that contains just the id (for the link) and the creator available_games = [{'creator': game.creator.username, 'id': game.pk} for game in Game.get_available_games()] # for the player's games, we're returning a list of games with the opponent and id player_games = Game.get_games_for_player(self.request.user) return context |
So, now if we runserver and hit http:localhost:8080/lobby/ (after logging in), we would get an error about a missing lobby.html template. We need to create that. With Channels you can use standard Django templates with javascript to send and receive messages over websockets. For a small project, even one this small, that could work just fine. However, larger and more complicated projects would benefit from a nice front-end library to help manage everything. We’ll be using the popular React library from Facebook. So, let’s add that in now.
Continue on to the next page….
hello,
when I run your code, but the web don’t show the game, what’s happend?
Are you going through the tutorial? Or are you running the example project from github?
Do you have a similar project but with channels 2.0? Do you plan to update this one?
Yes, I’m actually working on one now. Hopefully I’ll have it ready in October, but my free time is tight right now.
I am getting an error, “ImportError: cannot import name ‘Group'”, while trying to import Group from channels in models.py. I am using channels 2.0.2. Please help.
Yeah, this tutorial is based on a very early version of channels – 0.17, I believe. Version 2.0.2 has quite of few changes from that old version, including requiring Python3. Here is an issue on the official github that deals with the error you’re seeing: https://github.com/django/channels/issues/989
I will try to get the tutorial updated to Django 2.0, Python 3, and the latest channels version soon.
Hey,
I’m trying to follow your tutorial, but I keep running into the following error:
OSError at /lobby/
Error reading [PATH]\webpack-stats.json. Are you sure webpack has generated the file and the path is correct?
Any idea how to fix it?
Sorry, I didn’t reply sooner. If the file is being generated, check your STATS_FILE setting under WEBPACK_LOADER in settings.py. It’s possible that it isn’t pointing to the correct location for the file.
If the file isn’t being generated, webpack may not be running properly. Are you seeing errors when you run wepback?
That is very good, I was making similar stuff as a video on my language and this sharing is very useful.
Power of sharing, thanks again.
When will the index.jsx be called ?
The index.jsx files are bundled with webpack (set on lines 8 and 9 in webpack.config.js). Those are the bundles that are loaded with the Django Webpack Loader and rendered on the related html pages (lobby.html and game.html).
I got a few questions to make the game a little bit more complex
Is it possible to allow to the user give a name for the room?
Is it possible to allow more than 2 players? (obviously adding the correct img stuff)
If the answer from above is yes, can the user choose a “limit” for ppl to connect before starting the game?
Cheers!
Yes, each of those suggestions would work just fine:
Thanks for this django/react tutorial. It is awesome helping me get my head around React and putting all the pieces together!!
Great to hear! I’m glad you found it useful. Thanks for letting me know.
Thank you so much for writing this tutorial!
From your description, it seems like moves and player messages should update automatically, however when I run it, they only update after a manual refresh of the page. Am I misunderstanding how the website works?
Thanks
The log should update when new chat messages are created or moves are made. I would check to make sure the game.send_game_update() method is being called at the appropriate times. Also, check the browser console for any errors on the client-side. That may give you more of a clue to the issue as well.
Thanks for writing this tutorial! However, some things that may be confusing to a beginner:
startapp game
is going to create a game/views.py by default, and anyone that forgets to move views.py is going to have a package conflict. Also,from views import *
inside __init__.py should befrom .views import *
just to avoid any namespace mismatches. I recommend you keep the default views.py.Thanks for your comments, Daniel. Yes, I thought that splitting the views could be confusing, but it’s something I like to do to organize views of different types. So here, I didn’t want to combine the DRF API views with the “standard” Django views. Also, the tutorial wasn’t exactly intended for beginners, but maybe I can clarify the views split a little more in the post. Also, thanks for catching the import fix. I had already updated the imports in the git project, but missed it in the post.
Hello! Great tutorial! I have a question, I want to use foundation-sites in my project. I am following you tutorial and instead of using bootstrap precompiled css I would like to install foundation with bower maybe? I know how to use the “foundation new” command to create a new project but I would like not to create a new project but integrate foundation sites with mine!
Hey David, sorry I haven’t worked with Foundation yet. But it looks like you can just use the CSS itself and avoid the CLI site generation: http://foundation.zurb.com/sites/download/
You could just include this as you would any other CSS, and my guess is that if you install the full Foundation package with NPM, you could just reference the CSS there as well.
not sure if it’s a django versioning thing or what, but on page 1 of this tutorial you are no longer allowed to specify views with strings and they must be callable, suggested edit follows:
original:
from django.conf.urls import url
from django.contrib import admin
from game.views import *
urlpatterns = [
url(r’^admin/’, admin.site.urls),
url(r’^register/’, CreateUserView.as_view()),
url(r’^login/$’, ‘django.contrib.auth.views.login’, {‘template_name’: ‘login.html’}),
url(r’^logout/$’, ‘django.contrib.auth.views.logout’, {‘next_page’: ‘/’}),
url(r’^$’, HomeView.as_view())
]
edit:
from django.conf.urls import url
from django.contrib import admin
from django.contrib.auth.views import login, logout
from game.views import *
urlpatterns = [
url(r’^admin/’, admin.site.urls),
url(r’^register/’, CreateUserView.as_view()),
url(r’^login/$’, login, {‘template_name’: ‘login.html’}),
url(r’^logout/$’, logout, {‘next_page’: ‘/’}),
url(r’^$’, HomeView.as_view())
]
Yep – you’re right, thanks for letting me know! I’ve updated the urls.py code.
Thank you so much, one of the most complete tutorials I have seen. Not all persons are willing to teach this things together, and the complexity of the scenario gives us good bases. This types of tutorials (even paid) are hard to find. Again, thank you.
Thank you for the nice comments! I’m glad you found the tutorial helpful.
Can you please show one example on how I can make api post call from react to django drf?
Is there any reason why all “post” calls are done via sockets not api in this tutorial?
You can see a few examples of calls from React to the DRF backend in my post. For example, take a look at the getGame() method on the GameBoard.jsx component. That method calls the DRF SingleGameViewSet endpoint to get game details.
And as I mentioned in the post, I tried to mix up different ways of getting data from the Django backend to the React frontend. I wanted to show different ways of achieving the same thing: sending data through the standard Django response via context, DRF calls to the backend, and Django Channels websocket calls. In reality, this isn’t how I would structure a production app, but I was hoping it would be informative. Hopefully not confusing at the same time.
Wow! Thanks again for sharing this tutorial. I am amazed by your generosity. The tutorial is intense.
Let me give some suggesting for how you can improve it. I found that very often the flow of tutorial is going from big concepts (code snippets) to a smaller ones. For example several times you are first putting together some views, react components or api_views and then go to show some serializers, consumers, routers, urls and so on. This can be sometimes confusing, since a student can receive error messages that those small parts are not yet existed. I think going from small concepts to bigger would be more easily to understand. Also, please check your github code. I think it does not work if you just download it and want to use. Several imports are configured improperly (signal in apps.py, for instance)
Thank you again!
Thanks for suggestions. Yes this was my first large tutorial so it definitely could be optimized and improved. I did the GitHub project well before the post, but it worked for me when I last tried it. It could be a python2/python3 import issue. I’ll update that tonight and get it working.
not sure if the instruction on page 8 is correct
class ClaimSquareView(APIView):
def get_object(self, pk):
try:
return Game.objects.get(pk=pk)
except Game.DoesNotExist:
raise Http404
def put(self, request, pk):
game = self.get_object(pk)
# update the owner
print(game)
return Response(serializer.errors)
– no import for Http404
– serializer is not defined
You’re right! That view isn’t even needed… I think I started going that direction to claim a square, but moved it to a Channels call using the consumer instead. I’ve removed that reference and the url reference.
Thank you very much for your suggestions and bug reports! I’ve added you to the “Thank you” section at the bottom of the post.
on page 7 views.py also should import
from django.contrib import messages
Added it, thank you.
Also, in my setup in view/__init__.py instead of
from views import *
from api_views import *
I need to enter
from .views import *
from .api_views import *
Yes this is probably because you’re on Python 3 and implicit relative imports like that won’t work. I’m on 2.7 and they work with it. Thanks for pointing that out. I’ll update the post to note this.
on the page 2, it is very important to highlights this setting in the settings.py
STATICFILES_DIRS = [
os.path.join(BASE_DIR, “static”),
]
it is not something that is added by default if start project with django-admin tools
Thanks, yes when I first talk about the settings file, I recommended overwriting all of the default code with what I show in the post. I’ll make sure that it’s more clear.
Thanks, confirm MIDDLEWARE_CLASSES fixed the issue with ‘AsgiRequest’ object has no attribute ‘session’
after following all instruction on the page 4, cannot login, getting this error
AttributeError at /login/
‘AsgiRequest’ object has no attribute ‘session’
Request Method: POST
Request URL: http://127.0.0.1:8080/login/
Django Version: 1.9.12
Exception Type: AttributeError
Exception Value:
‘AsgiRequest’ object has no attribute ‘session’
Exception Location: /home/gideon/virtualenvs/vEnv_my_obstruct_game/lib/python3.4/site-packages/django/contrib/auth/__init__.py in login, line 101
Python Executable: /home/gideon/virtualenvs/vEnv_my_obstruct_game/bin/python
Python Version: 3.4.3
Python Path:
[‘/home/gideon/PycharmProjects/my_obstruct_game’,
‘/home/gideon/virtualenvs/vEnv_my_obstruct_game/lib/python3.4/site-packages/setuptools-18.1-py3.4.egg’,
‘/home/gideon/virtualenvs/vEnv_my_obstruct_game/lib/python3.4/site-packages/pip-7.1.0-py3.4.egg’,
‘/home/gideon/PycharmProjects/my_obstruct_game’,
‘/usr/lib/python3.4’,
‘/usr/lib/python3.4/plat-x86_64-linux-gnu’,
‘/usr/lib/python3.4/lib-dynload’,
‘/home/gideon/virtualenvs/vEnv_my_obstruct_game/lib/python3.4/site-packages’]
Server time: Mon, 6 Feb 2017 15:53:42 +0000
Traceback Switch to copy-and-paste view
/home/gideon/virtualenvs/vEnv_my_obstruct_game/lib/python3.4/site-packages/django/core/handlers/base.py in get_response
response = self.process_exception_by_middleware(e, request) …
▶ Local vars
/home/gideon/virtualenvs/vEnv_my_obstruct_game/lib/python3.4/site-packages/channels/handler.py in process_exception_by_middleware
return super(AsgiHandler, self).process_exception_by_middleware(exception, request)
One thing that could cause this with Django 1.9+ is if you have MIDDLEWARE instead of MIDDLEWARE_CLASSES in your settings.py file. Can you check that?
It seems there is an error in this instruction
npm install –save-dev react react-dom webpack webpack-bundle-tracker babel-core babel babel-loadernpm babel-preset-es2015 react-websocket babel-preset-es2015 babel-preset-react jquery
“babel-loadernpm” should read “babel-loader”
Yep, you’re right – a little copy-paste issue on my part. It’s fixed now. Thanks for letting me know!
I have not yet finished your tutorial, but for what I see I can tell you huge THANK YOU!
Thank you, I hope it you find it useful. Please let me know if you have any issues!