Skip to content

Commit

Permalink
Initial commit: Liveblog
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewgodwin committed Mar 23, 2016
0 parents commit 47c02e9
Show file tree
Hide file tree
Showing 27 changed files with 718 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
@@ -0,0 +1 @@
*.sqlite3
12 changes: 12 additions & 0 deletions README.rst
@@ -0,0 +1,12 @@
Channels Examples
=================

This is a repository of simple, well-commented examples of how to implement a
few different site features in Django Channels.

Each example is a standalone Django project; instructions for running them
are in their own README files.

Pull requests of whole new examples, or small improvements to existing ones,
are welcome; however, they should still be simple and not add too many complex
features or additional steps to running it.
71 changes: 71 additions & 0 deletions liveblog/README.rst
@@ -0,0 +1,71 @@
Liveblog
========

Illustrates a "liveblog" using Channels. This is a page that shows a series
of short-form posts in descending date order, with new ones appearing at the
top as they're posted.

The site supports multiple liveblogs at once, and clients only listen for new
posts on the blog they're currently viewing.

When you view a liveblog page, we open a WebSocket to Django, and the consumer
there adds it to a Group based on the liveblog slug it used in the URL of the
socket. Then, in the ``save()`` method of the ``Post`` model, we send notifications
onto that Group that all currently connected clients pick up on, and insert
the new post at the top of the page.

Updates are also supported - the notification is sent with an ID, and if a post
with that ID is already on the page, the JavaScript just replaces its content
instead.


Installation
------------

Make a new virtualenv for the project, and run::

pip install -r requirements.txt

Then, you'll need Redis running locally; the settings are configured to
point to ``localhost``, port ``6379``, but you can change this in the
``CHANNEL_LAYERS`` setting in ``settings.py`.
Finally, run::
python manage.py migrate
python manage.py runserver
Suggested Exercises
-------------------
If you want to try out making some changes and getting a feel for Channels,
here's some ideas and hints on how to do them:
* Make the posts disappear immediately on post deletion. You'll need to send
notifications triggered on the model delete, in a similar way to the ones
for save, and modify the JavaScript to understand them.
* Implement a view of all liveblogs at once. You'll need to make a new group
that people who connect to this endpoint will subscribe to, insert into that
group from the save() method as well as the existing per-liveblog group,
and make new consumers to add and remove people from that group as they
connect to a WebSocket on a different path (including new routing entries).
* Make the front page list of liveblogs update. You'll need another new WebSocket
endpoint (with new consumers and routing), a new group to send updates down,
and to tie that group into LiveBlog's save process. Decide if you want to
send differential updates, or just re-send the whole list each time a new one
is created. Both have advantages - what are they?
* Try adding Like functionality to the posts, so viewers can "like" a post and
it's sent back over the WebSocket to a ``websocket.receive`` consumer that
saves the like into the database, then propagates it to all existing clients.
Can you reuse the existing Post.save() hook? What happens if hundreds
of people are trying to like every second?


Further Reading
---------------

You can find the Channels documentation at http://channels.readthedocs.org
Empty file added liveblog/liveblog/__init__.py
Empty file.
11 changes: 11 additions & 0 deletions liveblog/liveblog/asgi.py
@@ -0,0 +1,11 @@
"""
ASGI entrypoint file for default channel layer.
Points to the channel layer configured as "default" so you can point
ASGI applications at "liveblog.asgi:channel_layer" as their channel layer.
"""

import os
from channels.asgi import get_channel_layer
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "liveblog.settings")
channel_layer = get_channel_layer()
21 changes: 21 additions & 0 deletions liveblog/liveblog/routing.py
@@ -0,0 +1,21 @@
from channels import route
from posts.consumers import connect_blog, disconnect_blog


# The channel routing defines what channels get handled by what consumers,
# including optional matching on message attributes. WebSocket messages of all
# types have a 'path' attribute, so we're using that to route the socket.
# While this is under stream/ compared to the HTML page, we could have it on the
# same URL if we wanted; Daphne separates by protocol as it negotiates with a browser.
channel_routing = [
# Called when incoming WebSockets connect
route("websocket.connect", connect_blog, path=r'^/liveblog/(?P<slug>[^/]+)/stream/$'),

# Called when the client closes the socket
route("websocket.disconnect", disconnect_blog, path=r'^/liveblog/(?P<slug>[^/]+)/stream/$'),

# A default "http.request" route is always inserted by Django at the end of the routing list
# that routes all unmatched HTTP requests to the Django view system. If you want lower-level
# HTTP handling - e.g. long-polling - you can do it here and route by path, and let the rest
# fall through to normal views.
]
113 changes: 113 additions & 0 deletions liveblog/liveblog/settings.py
@@ -0,0 +1,113 @@
"""
Django settings for liveblog project.
Generated by 'django-admin startproject' using Django 1.10.dev20151126161447.
For more information on this file, see
https://docs.djangoproject.com/en/dev/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/dev/ref/settings/
"""

import os

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# SECURITY WARNING: keep the secret key used in production secret! And don't use debug=True in production!
SECRET_KEY = 'imasecret'
DEBUG = True
ALLOWED_HOSTS = []

# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'channels',
'posts',
]

MIDDLEWARE_CLASSES = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'liveblog.urls'

# Channel layer definitions
# http://channels.readthedocs.org/en/latest/deploying.html#setting-up-a-channel-backend
CHANNEL_LAYERS = {
"default": {
# This example app uses the Redis channel layer implementation asgi_redis
"BACKEND": "asgi_redis.RedisChannelLayer",
"CONFIG": {
"hosts": [("localhost", 6379)],
},
"ROUTING": "liveblog.routing.channel_routing",
},
}

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

WSGI_APPLICATION = 'liveblog.wsgi.application'


# Database
# https://docs.djangoproject.com/en/dev/ref/settings/#databases

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}


# Password validation
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
# Deliberately turned off for this example.
AUTH_PASSWORD_VALIDATORS = []


# Internationalization
# https://docs.djangoproject.com/en/dev/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/dev/howto/static-files/

STATIC_URL = '/static/'
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static"),
]
10 changes: 10 additions & 0 deletions liveblog/liveblog/urls.py
@@ -0,0 +1,10 @@
from django.conf.urls import url
from django.contrib import admin
from posts.views import index, liveblog


urlpatterns = [
url(r'^$', index),
url(r'^liveblog/(?P<slug>[^/]+)/$', liveblog),
url(r'^admin/', admin.site.urls),
]
16 changes: 16 additions & 0 deletions liveblog/liveblog/wsgi.py
@@ -0,0 +1,16 @@
"""
WSGI config for liveblog project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/
"""

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "liveblog.settings")

application = get_wsgi_application()
10 changes: 10 additions & 0 deletions liveblog/manage.py
@@ -0,0 +1,10 @@
#!/usr/bin/env python
import os
import sys

if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "liveblog.settings")

from django.core.management import execute_from_command_line

execute_from_command_line(sys.argv)
Empty file added liveblog/posts/__init__.py
Empty file.
18 changes: 18 additions & 0 deletions liveblog/posts/admin.py
@@ -0,0 +1,18 @@
from django.contrib import admin
from .models import Liveblog, Post


admin.site.register(
Liveblog,
list_display=["id", "title", "slug"],
list_display_links=["id", "title"],
ordering=["title"],
prepopulated_fields={"slug": ("title",)},
)


admin.site.register(
Post,
list_display=["id", "liveblog", "created", "body_intro"],
ordering=["-id"],
)
5 changes: 5 additions & 0 deletions liveblog/posts/apps.py
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class PostsConfig(AppConfig):
name = 'posts'
51 changes: 51 additions & 0 deletions liveblog/posts/consumers.py
@@ -0,0 +1,51 @@
import json
from channels import Group
from .models import Liveblog


# The "slug" keyword argument here comes from the regex capture group in
# routing.py.
def connect_blog(message, slug):
"""
When the user opens a WebSocket to a liveblog stream, adds them to the
group for that stream so they receive new post notifications.
The notifications are actually sent in the Post model on save.
"""
# Try to fetch the liveblog by slug; if that fails, close the socket.
try:
liveblog = Liveblog.objects.get(slug=slug)
except Liveblog.DoesNotExist:
# You can see what messages back to a WebSocket look like in the spec:
# http://channels.readthedocs.org/en/latest/asgi.html#send-close
# Here, we send "close" to make Daphne close off the socket, and some
# error text for the client.
message.reply_channel.send({
# WebSockets send either a text or binary payload each frame.
# We do JSON over the text portion.
"text": json.dumps({"error": "bad_slug"}),
"close": True,
})
# Each different client has a different "reply_channel", which is how you
# send information back to them. We can add all the different reply channels
# to a single Group, and then when we send to the group, they'll all get the
# same message.
Group(liveblog.group_name).add(message.reply_channel)


def disconnect_blog(message, slug):
"""
Removes the user from the liveblog group when they disconnect.
Channels will auto-cleanup eventually, but it can take a while, and having old
entries cluttering up your group will reduce performance.
"""
try:
liveblog = Liveblog.objects.get(slug=slug)
except Liveblog.DoesNotExist:
# This is the disconnect message, so the socket is already gone; we can't
# send an error back. Instead, we just return from the consumer.
return
# It's called .discard() because if the reply channel is already there it
# won't fail - just lie the set() type.
Group(liveblog.group_name).discard(message.reply_channel)
35 changes: 35 additions & 0 deletions liveblog/posts/migrations/0001_initial.py
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.dev20151126161447 on 2016-03-23 18:51
from __future__ import unicode_literals

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='Liveblog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('slug', models.SlugField()),
],
),
migrations.CreateModel(
name='Post',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField()),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('liveblog', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='posts.Liveblog')),
],
),
]
Empty file.

0 comments on commit 47c02e9

Please sign in to comment.