Matthew Daly's Blog

I'm a web developer in Norfolk. This is my blog...

15th February 2014 5:45 pm

Django Blog Tutorial - the Next Generation - Part 4

Hello again! As promised, in this instalment we’ll implement categories and tags, as well as an RSS feed.

As usual, we need to switch into our virtualenv:

$ source venv/bin/activate

Categories

It’s worth taking a little time at this point to set out what we mean by categories and tags in this case, as the two can be very similar. In this case, we’ll use the following criteria:

  • A post can have only one category, or none, but a category can be applied to any number of posts
  • A post can have any number of tags, and a tag can be applied to any number of posts

If you’re not too familiar with relational database theory, the significance of this may not be apparent, so here’s a quick explanation. Because the categories are limited to one per post, the relationship between a post and a category is known as one-to-many. In other words, one post can only have one category, but one category can have many posts. You can therefore define the categories in one table in your database, and refer to them by their ID (the reference to the category in the post table is referred to as a foreign key).

As usual, we will write a test first. Open up blogengine/tests.py and edit the line importing the Post model as follows:

from blogengine.models import Post, Category

Also, add the following method before test_create_post:

def test_create_category(self):
# Create the category
category = Category()
# Add attributes
category.name = 'python'
category.description = 'The Python programming language'
# Save it
category.save()
# Check we can find it
all_categories = Category.objects.all()
self.assertEquals(len(all_categories), 1)
only_category = all_categories[0]
self.assertEquals(only_category, category)
# Check attributes
self.assertEquals(only_category.name, 'python')
self.assertEquals(only_category.description, 'The Python programming language')

This test checks that we can create categories. But categories aren’t much use unless we can actually apply them to our posts. So we need to edit our Post model as well, and to do so we need to have a test in place first. Edit the test_create_post method as follows:

def test_create_post(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
# Set the attributes
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
# Save it
post.save()
# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Check attributes
self.assertEquals(only_post.title, 'My first post')
self.assertEquals(only_post.text, 'This is my first blog post')
self.assertEquals(only_post.slug, 'my-first-post')
self.assertEquals(only_post.site.name, 'example.com')
self.assertEquals(only_post.site.domain, 'example.com')
self.assertEquals(only_post.pub_date.day, post.pub_date.day)
self.assertEquals(only_post.pub_date.month, post.pub_date.month)
self.assertEquals(only_post.pub_date.year, post.pub_date.year)
self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
self.assertEquals(only_post.pub_date.second, post.pub_date.second)
self.assertEquals(only_post.author.username, 'testuser')
self.assertEquals(only_post.author.email, 'user@example.com')
self.assertEquals(only_post.category.name, 'python')
self.assertEquals(only_post.category.description, 'The Python programming language')

What we’re doing here is adding a category attribute to the posts. This attribute contains a reference to a Category object.

Now, we also want to test adding, editing, and deleting a category from the admin interface. Add this code to the AdminTest class:

def test_create_category(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/category/add/')
self.assertEquals(response.status_code, 200)
# Create the new category
response = self.client.post('/admin/blogengine/category/add/', {
'name': 'python',
'description': 'The Python programming language'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new category now in database
all_categories = Category.objects.all()
self.assertEquals(len(all_categories), 1)
def test_edit_category(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the category
response = self.client.post('/admin/blogengine/category/1/', {
'name': 'perl',
'description': 'The Perl programming language'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check category amended
all_categories = Category.objects.all()
self.assertEquals(len(all_categories), 1)
only_category = all_categories[0]
self.assertEquals(only_category.name, 'perl')
self.assertEquals(only_category.description, 'The Perl programming language')
def test_delete_category(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the category
response = self.client.post('/admin/blogengine/category/1/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check category deleted
all_categories = Category.objects.all()
self.assertEquals(len(all_categories), 0)

This is very similar to the prior code for the posts, and just checks we can create categories via the admin. We also need to check we can apply these categories to posts, and that they don’t break the existing tests:

def test_create_post(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)
# Create the new post
response = self.client.post('/admin/blogengine/post/add/', {
'title': 'My first post',
'text': 'This is my first post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-first-post',
'site': '1',
'category': '1'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new post now in database
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
def test_edit_post(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the post
response = self.client.post('/admin/blogengine/post/1/', {
'title': 'My second post',
'text': 'This is my second blog post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-second-post',
'site': '1',
'category': '1'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post.title, 'My second post')
self.assertEquals(only_post.text, 'This is my second blog post')
def test_delete_post(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.site = site
post.author = author
post.category = category
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the post
response = self.client.post('/admin/blogengine/post/1/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check post deleted
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 0)

Here we basically take our existing post tests in the admin interface and add the category to them. Finally, we edit the PostViewTest class to include categories:

class PostViewTest(BaseAcceptanceTest):
def test_index(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Fetch the index
response = self.client.get('/')
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
def test_post_page(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the post URL
post_url = only_post.get_absolute_url()
# Fetch the post
response = self.client.get(post_url)
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)

Now it’s time to run our tests:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
E
======================================================================
ERROR: blogengine.tests (unittest.loader.ModuleImportFailure)
----------------------------------------------------------------------
ImportError: Failed to import test module: blogengine.tests
Traceback (most recent call last):
File "/usr/local/Cellar/python/2.7.6/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/loader.py", line 254, in _find_tests
module = self._get_module_from_name(name)
File "/usr/local/Cellar/python/2.7.6/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/loader.py", line 232, in _get_module_from_name
__import__(name)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 3, in <module>
from blogengine.models import Post, Category
ImportError: cannot import name Category
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)
Destroying test database for alias 'default'...

This is the expected result. We need to create our Category model. So let’s do that:

from django.db import models
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
# Create your models here.
class Category(models.Model):
name = models.CharField(max_length=200)
description = models.TextField()
class Post(models.Model):
title = models.CharField(max_length=200)
pub_date = models.DateTimeField()
text = models.TextField()
slug = models.SlugField(max_length=40, unique=True)
author = models.ForeignKey(User)
site = models.ForeignKey(Site)
category = models.ForeignKey(Category, blank=True, null=True)
def get_absolute_url(self):
return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
def __unicode__(self):
return self.title
class Meta:
ordering = ["-pub_date"]

Note that we add Category before Post - this is because Category is a foreign key in Post, and must be defined in order to be used. Also, note that we add the category attribute as a ForeignKey field, like User and Site, indicating that it is an item in another table being references.

We also allow for category to be blank or null, so the user does not have to apply a category if they don’t wish to.

If we run our tests, they should still fail:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
EEFEEEEE...EE
======================================================================
ERROR: test_create_category (blogengine.tests.PostTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 20, in test_create_category
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
ERROR: test_create_post (blogengine.tests.PostTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 37, in test_create_post
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
ERROR: test_create_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 215, in test_create_post
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
ERROR: test_delete_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 192, in test_delete_category
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
ERROR: test_delete_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 304, in test_delete_post
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
ERROR: test_edit_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 165, in test_edit_category
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
ERROR: test_edit_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 250, in test_edit_post
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
ERROR: test_index (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 353, in test_index
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
ERROR: test_post_page (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 403, in test_post_page
category.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_category
======================================================================
FAIL: test_create_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 142, in test_create_category
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 13 tests in 3.393s
FAILED (failures=1, errors=9)
Destroying test database for alias 'default'...

The category table hasn’t yet been created, so we need to use South to create and run the migrations:

$ python manage.py schemamigration --auto blogengine
$ python manage.py migrate

If we then run our tests again, some of them will still fail:

Creating test database for alias 'default'...
..F.F.F......
======================================================================
FAIL: test_create_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 142, in test_create_category
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_delete_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 201, in test_delete_category
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_edit_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 175, in test_edit_category
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 13 tests in 4.047s
FAILED (failures=3)
Destroying test database for alias 'default'...

That’s because we haven’t registered the categories in the admin. So, that’s our next job:

import models
from django.contrib import admin
from django.contrib.auth.models import User
class PostAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("title",)}
exclude = ('author',)
def save_model(self, request, obj, form, change):
obj.author = request.user
obj.save()
admin.site.register(models.Category)
admin.site.register(models.Post, PostAdmin)

Now we try again:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
.............
----------------------------------------------------------------------
Ran 13 tests in 4.092s
OK
Destroying test database for alias 'default'...

It passes! Let’s do a quick sense check before committing. Run the server:

$ python manage.py runserver

If you visit the admin, you’ll see the text for category is Categorys, which is incorrect. We also don’t have a good representation of the category in the admin. Let’s fix that:

from django.db import models
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
# Create your models here.
class Category(models.Model):
name = models.CharField(max_length=200)
description = models.TextField()
def __unicode__(self):
return self.name
class Meta:
verbose_name_plural = 'categories'
class Post(models.Model):
title = models.CharField(max_length=200)
pub_date = models.DateTimeField()
text = models.TextField()
slug = models.SlugField(max_length=40, unique=True)
author = models.ForeignKey(User)
site = models.ForeignKey(Site)
category = models.ForeignKey(Category, blank=True, null=True)
def get_absolute_url(self):
return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
def __unicode__(self):
return self.title
class Meta:
ordering = ["-pub_date"]

Let’s commit our changes:

$ git add blogengine/
$ git commit -m 'Implemented categories'

Now, as yet our categories don’t actually do all that much. We would like to be able to:

  • List all posts under a category
  • Show the post category at the base of the post

So, let’s implement that. First, as usual, we implement tests first:

class PostViewTest(BaseAcceptanceTest):
def test_index(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Fetch the index
response = self.client.get('/')
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post category is in the response
self.assertTrue(post.category.name in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
def test_post_page(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the post URL
post_url = only_post.get_absolute_url()
# Fetch the post
response = self.client.get(post_url)
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post category is in the response
self.assertTrue(post.category.name in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)

All we do here is assert that for both the post pages and the index, the text from the category name is shown in the response. We also need to check the category-specific route works. Add this method to PostViewTest:

def test_category_page(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the category URL
category_url = post.category.get_absolute_url()
# Fetch the category
response = self.client.get(category_url)
self.assertEquals(response.status_code, 200)
# Check the category name is in the response
self.assertTrue(post.category.name in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)

This is very similar to the previous tests, but fetches the absolute URL for the category, and ensures the category name and post content are shown. Now, let’s run our new tests:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
...........EFF
======================================================================
ERROR: test_category_page (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 494, in test_category_page
category_url = post.category.get_absolute_url()
AttributeError: 'Category' object has no attribute 'get_absolute_url'
======================================================================
FAIL: test_index (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 391, in test_index
self.assertTrue(post.category.name in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_post_page (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 446, in test_post_page
self.assertTrue(post.category.name in response.content)
AssertionError: False is not true
----------------------------------------------------------------------
Ran 14 tests in 5.017s
FAILED (failures=2, errors=1)
Destroying test database for alias 'default'...

Let’s take a look at why they failed. test_category_page failed because the Category object had no method get_absolute_url. So we need to implement one. To do so, we really need to add a slug field, like the posts already have. Ideally, we want this to be populated automatically, but with the option to create one manually. So, edit the models as follows:

from django.db import models
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.utils.text import slugify
# Create your models here.
class Category(models.Model):
name = models.CharField(max_length=200)
description = models.TextField()
slug = models.SlugField(max_length=40, unique=True, blank=True, null=True)
def save(self):
if not self.slug:
self.slug = slugify(unicode(self.name))
super(Category, self).save()
def get_absolute_url(self):
return "/category/%s/" % (self.slug)
def __unicode__(self):
return self.name
class Meta:
verbose_name_plural = 'categories'
class Post(models.Model):
title = models.CharField(max_length=200)
pub_date = models.DateTimeField()
text = models.TextField()
slug = models.SlugField(max_length=40, unique=True)
author = models.ForeignKey(User)
site = models.ForeignKey(Site)
category = models.ForeignKey(Category, blank=True, null=True)
def get_absolute_url(self):
return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
def __unicode__(self):
return self.title
class Meta:
ordering = ["-pub_date"]

We’re adding the slug attribute to the Category model here. However, we’re also overriding the save method to detect if the slug is set, and if not, to create a slug using the slugify function, and set it as the category’s slug. We also define an absolute URL for the category.

Now, if you run the tests, they will fail because we haven’t made the changes to the database. So, we use South again:

$ python manage.py schemamigration --auto blogengine

Then run the migration:

$ python manage.py migrate

Now, running our tests will show that the tables are in place, but we still have some work to do. The index and post pages don’t show our categories, so we’ll fix that. First, we’ll fix our post list:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
{% for post in object_list %}
<div class="post">
<h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text|custom_markdown }}
</div>
<a href="{{ post.category.get_absolute_url }}">{{ post.category.name }}</a>
{% endfor %}
{% if page_obj.has_previous %}
<a href="/{{ page_obj.previous_page_number }}/">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a href="/{{ page_obj.next_page_number }}/">Next Page</a>
{% endif %}
{% endblock %}

Next, we’ll take care of our post detail page:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
<div class="post">
<h1>{{ object.title }}</h1>
<h3>{{ object.pub_date }}</h3>
{{ object.text|custom_markdown }}
<a href="{{ object.category.get_absolute_url }}">{{ object.category.name }}</a>
<h4>Comments</h4>
<div class="fb-comments" data-href="http://{{ post.site }}{{ post.get_absolute_url }}" data-width="470" data-num-posts="10"></div>
</div>
{% endblock %}

Note that in both cases we include a link to the category URL.

Now, we should only have one failing test outstanding - the category page. For this, generic views aren’t sufficient as we need to limit the queryset to only show those posts with a specific category. Fortunately, we can extend Django’s generic views to add this functionality. First, we edit our URLconfs:

from django.conf.urls import patterns, url
from django.views.generic import ListView, DetailView
from blogengine.models import Post, Category
from blogengine.views import CategoryListView
urlpatterns = patterns('',
# Index
url(r'^(?P<page>\d+)?/?$', ListView.as_view(
model=Post,
paginate_by=5,
)),
# Individual posts
url(r'^(?P<pub_date__year>\d{4})/(?P<pub_date__month>\d{1,2})/(?P<slug>[a-zA-Z0-9-]+)/?$', DetailView.as_view(
model=Post,
)),
# Categories
url(r'^category/(?P<slug>[a-zA-Z0-9-]+)/?$', CategoryListView.as_view(
paginate_by=5,
model=Category,
)),
)

Note we import a new view from blogengine.views called CategoryListView. Next, we create that listview:

from django.shortcuts import render
from django.views.generic import ListView
from blogengine.models import Category, Post
# Create your views here.
class CategoryListView(ListView):
def get_queryset(self):
slug = self.kwargs['slug']
try:
category = Category.objects.get(slug=slug)
return Post.objects.filter(category=category)
except Category.DoesNotExist:
return Post.objects.none()

This is quite simple. We import the ListView, as well as our models. Then we extend ListView by getting the slug from the request, fetching the appropriate category, and returning only those posts that have that category. If the category does not exist, we return the empty Post object list. We haven’t had to set the template manually as it is inherited from ListView.

If you run the tests, they should now pass:

Creating test database for alias 'default'...
..............
----------------------------------------------------------------------
Ran 14 tests in 5.083s
OK
Destroying test database for alias 'default'...

So let’s commit our changes:

$ git add blogengine/ templates/
$ git commit -m 'Categories are now shown'

Tags

Tags are fairly similar to categories, but more complex. The relationship they have is called many-to-many - in other words, a tag can be applied to many posts, and one post can have many tags. This is more difficult to model with a relational database. The usual way to do so is to create an intermediate table between the posts and tags, to identify mappings between the two. Fortunately, Django makes this quite easy.

Let’s write the tests for our tagging system. As with the categories, we’ll write the tests for creating and editing them first, and add in tests for them being visible later. First we’ll create a test for creating a new tag object:

def test_create_tag(self):
# Create the tag
tag = Tag()
# Add attributes
tag.name = 'python'
tag.description = 'The Python programming language'
# Save it
tag.save()
# Check we can find it
all_tags = Tag.objects.all()
self.assertEquals(len(all_tags), 1)
only_tag = all_tags[0]
self.assertEquals(only_tag, tag)
# Check attributes
self.assertEquals(only_tag.name, 'python')
self.assertEquals(only_tag.description, 'The Python programming language')

Next, we’ll amend the test for creating a post to include tags:

def test_create_post(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the tag
tag = Tag()
tag.name = 'python'
tag.description = 'The Python programming language'
tag.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
# Set the attributes
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
# Save it
post.save()
# Add the tag
post.tags.add(tag)
post.save()
# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Check attributes
self.assertEquals(only_post.title, 'My first post')
self.assertEquals(only_post.text, 'This is my first blog post')
self.assertEquals(only_post.slug, 'my-first-post')
self.assertEquals(only_post.site.name, 'example.com')
self.assertEquals(only_post.site.domain, 'example.com')
self.assertEquals(only_post.pub_date.day, post.pub_date.day)
self.assertEquals(only_post.pub_date.month, post.pub_date.month)
self.assertEquals(only_post.pub_date.year, post.pub_date.year)
self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
self.assertEquals(only_post.pub_date.second, post.pub_date.second)
self.assertEquals(only_post.author.username, 'testuser')
self.assertEquals(only_post.author.email, 'user@example.com')
self.assertEquals(only_post.category.name, 'python')
self.assertEquals(only_post.category.description, 'The Python programming language')
# Check tags
post_tags = only_post.tags.all()
self.assertEquals(len(post_tags), 1)
only_post_tag = post_tags[0]
self.assertEquals(only_post_tag, tag)
self.assertEquals(only_post_tag.name, 'python')
self.assertEquals(only_post_tag.description, 'The Python programming language')

Note the difference in how we apply the tags. Because a post can have more than one tag, we can’t just define post.tag in the same way. Instead, we have post.tags, which you can think of as a list, and we use the add method to add a new tag. Note also that the post must already exist before we can add a tag.

We also need to create acceptance tests for creating, editing and deleting tags:

def test_create_tag(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/tag/add/')
self.assertEquals(response.status_code, 200)
# Create the new tag
response = self.client.post('/admin/blogengine/tag/add/', {
'name': 'python',
'description': 'The Python programming language'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new tag now in database
all_tags = Tag.objects.all()
self.assertEquals(len(all_tags), 1)
def test_edit_tag(self):
# Create the tag
tag = Tag()
tag.name = 'python'
tag.description = 'The Python programming language'
tag.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the tag
response = self.client.post('/admin/blogengine/tag/1/', {
'name': 'perl',
'description': 'The Perl programming language'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check tag amended
all_tags = Tag.objects.all()
self.assertEquals(len(all_tags), 1)
only_tag = all_tags[0]
self.assertEquals(only_tag.name, 'perl')
self.assertEquals(only_tag.description, 'The Perl programming language')
def test_delete_tag(self):
# Create the tag
tag = Tag()
tag.name = 'python'
tag.description = 'The Python programming language'
tag.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the tag
response = self.client.post('/admin/blogengine/tag/1/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check tag deleted
all_tags = Tag.objects.all()
self.assertEquals(len(all_tags), 0)

These tests are virtually identical to those for the Category objects, as we plan for our Tag objects to be very similar. Finally, we need to amend the acceptance tests for Post objects to include a tag:

def test_create_post(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the tag
tag = Tag()
tag.name = 'python'
tag.description = 'The Python programming language'
tag.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)
# Create the new post
response = self.client.post('/admin/blogengine/post/add/', {
'title': 'My first post',
'text': 'This is my first post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-first-post',
'site': '1',
'category': '1',
'tags': '1'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new post now in database
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
def test_edit_post(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the tag
tag = Tag()
tag.name = 'python'
tag.description = 'The Python programming language'
tag.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.save()
post.tags.add(tag)
post.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the post
response = self.client.post('/admin/blogengine/post/1/', {
'title': 'My second post',
'text': 'This is my second blog post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-second-post',
'site': '1',
'category': '1',
'tags': '1'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post.title, 'My second post')
self.assertEquals(only_post.text, 'This is my second blog post')
def test_delete_post(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the tag
tag = Tag()
tag.name = 'python'
tag.description = 'The Python programming language'
tag.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.site = site
post.author = author
post.category = category
post.save()
post.tags.add(tag)
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the post
response = self.client.post('/admin/blogengine/post/1/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check post deleted
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 0)

Here we’re just adding tags to our Post objects.

Now it’s time to run our tests to make sure they fail as expected:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
E
======================================================================
ERROR: blogengine.tests (unittest.loader.ModuleImportFailure)
----------------------------------------------------------------------
ImportError: Failed to import test module: blogengine.tests
Traceback (most recent call last):
File "/usr/local/Cellar/python/2.7.6/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/loader.py", line 254, in _find_tests
module = self._get_module_from_name(name)
File "/usr/local/Cellar/python/2.7.6/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/loader.py", line 232, in _get_module_from_name
__import__(name)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 3, in <module>
from blogengine.models import Post, Category, Tag
ImportError: cannot import name Tag
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)
Destroying test database for alias 'default'...

So here we can’t import our Tag model, because we haven’t created it. So, we’ll do that:

class Tag(models.Model):
name = models.CharField(max_length=200)
description = models.TextField()
slug = models.SlugField(max_length=40, unique=True, blank=True, null=True)
def save(self):
if not self.slug:
self.slug = slugify(unicode(self.name))
super(Tag, self).save()
def get_absolute_url(self):
return "/tag/%s/" % (self.slug)
def __unicode__(self):
return self.name

Our Tag model is very much like our Category model, but we don’t need to change the verbose_name_plural value, and we amend the absolute URL to show it as a tag rather than a category.

We also need to amend our Post model to include a tags field:

class Post(models.Model):
title = models.CharField(max_length=200)
pub_date = models.DateTimeField()
text = models.TextField()
slug = models.SlugField(max_length=40, unique=True)
author = models.ForeignKey(User)
site = models.ForeignKey(Site)
category = models.ForeignKey(Category, blank=True, null=True)
tags = models.ManyToManyField(Tag)
def get_absolute_url(self):
return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
def __unicode__(self):
return self.title
class Meta:
ordering = ["-pub_date"]

Note that tags is a ManyToManyField, and we pass through the model we wish to use, much like we did with the categories. The difference is that one tag can be applied to many posts and a post can have many tags, so we need an intermediate database table to handle the relationship between the two. With Django’s ORM we can handle this quickly and easily.

Run our tests and they should still fail:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
.EE.EF.EE.EE......
======================================================================
ERROR: test_create_post (blogengine.tests.PostTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 64, in test_create_post
tag.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/models.py", line 34, in save
super(Tag, self).save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_tag
======================================================================
ERROR: test_create_tag (blogengine.tests.PostTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 41, in test_create_tag
tag.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/models.py", line 34, in save
super(Tag, self).save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_tag
======================================================================
ERROR: test_create_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 335, in test_create_post
tag.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/models.py", line 34, in save
super(Tag, self).save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_tag
======================================================================
ERROR: test_delete_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 440, in test_delete_post
tag.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/models.py", line 34, in save
super(Tag, self).save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_tag
======================================================================
ERROR: test_delete_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 306, in test_delete_tag
tag.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/models.py", line 34, in save
super(Tag, self).save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_tag
======================================================================
ERROR: test_edit_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 377, in test_edit_post
tag.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/models.py", line 34, in save
super(Tag, self).save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_tag
======================================================================
ERROR: test_edit_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 279, in test_edit_tag
tag.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/models.py", line 34, in save
super(Tag, self).save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_tag
======================================================================
FAIL: test_create_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 256, in test_create_tag
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 18 tests in 3.981s
FAILED (failures=1, errors=7)
Destroying test database for alias 'default'...

Again, we can easily see why they failed - the blogengine_tag table is not in place. So let’s create and run our migrations to fix that:

$ python manage.py schemamigration --auto blogengine
$ python manage.py migrate

Now, we run our tests again:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
.....F..F..F......
======================================================================
FAIL: test_create_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 256, in test_create_tag
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_delete_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 315, in test_delete_tag
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_edit_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 289, in test_edit_tag
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 18 tests in 5.124s
FAILED (failures=3)
Destroying test database for alias 'default'...

We can’t yet amend our tags in the admin, because we haven’t registered them. So we do that next:

import models
from django.contrib import admin
from django.contrib.auth.models import User
class PostAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("title",)}
exclude = ('author',)
def save_model(self, request, obj, form, change):
obj.author = request.user
obj.save()
admin.site.register(models.Category)
admin.site.register(models.Tag)
admin.site.register(models.Post, PostAdmin)

Now, if we run our tests, they should pass:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
..................
----------------------------------------------------------------------
Ran 18 tests in 5.444s
OK
Destroying test database for alias 'default'...

Time to commit our changes:

$ git add blogengine/
$ git commit -m 'Implemented tags'

Now, like with the categories beforehand, we want to be able to show the tags applied to a post at the base of it, and list all posts for a specific tag. So, first of all, we’ll amend our PostViewTest class to check for the tags:

class PostViewTest(BaseAcceptanceTest):
def test_index(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the tag
tag = Tag()
tag.name = 'perl'
tag.description = 'The Perl programming language'
tag.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
post.save()
post.tags.add(tag)
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Fetch the index
response = self.client.get('/')
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post category is in the response
self.assertTrue(post.category.name in response.content)
# Check the post tag is in the response
post_tag = all_posts[0].tags.all()[0]
self.assertTrue(post_tag.name in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
def test_post_page(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the tag
tag = Tag()
tag.name = 'perl'
tag.description = 'The Perl programming language'
tag.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
post.save()
post.tags.add(tag)
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the post URL
post_url = only_post.get_absolute_url()
# Fetch the post
response = self.client.get(post_url)
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post category is in the response
self.assertTrue(post.category.name in response.content)
# Check the post tag is in the response
post_tag = all_posts[0].tags.all()[0]
self.assertTrue(post_tag.name in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)

We create a tag near the top, and check for the text in the page (note that to avoid false positives from the categories, we set the name of the tags to something different). We do this on both the index and post pages.

We also need to put a test in place for the tag-specific page:

def test_tag_page(self):
# Create the tag
tag = Tag()
tag.name = 'python'
tag.description = 'The Python programming language'
tag.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.save()
post.tags.add(tag)
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the tag URL
tag_url = post.tags.all()[0].get_absolute_url()
# Fetch the tag
response = self.client.get(tag_url)
self.assertEquals(response.status_code, 200)
# Check the tag name is in the response
self.assertTrue(post.tags.all()[0].name in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)

Again, this is virtually identical to the category page, adjusted to allow for the fact that we need to get a specific tag. If we now run our tests, they should fail as expected:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
................FFF
======================================================================
FAIL: test_index (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 540, in test_index
self.assertTrue(post_tag.name in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_post_page (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 607, in test_post_page
self.assertTrue(post_tag.name in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_tag_page (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 714, in test_tag_page
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 19 tests in 5.375s
FAILED (failures=3)
Destroying test database for alias 'default'...

So, we need to implement the following things:

  • Show tags on the index page
  • Show tags on the post pages
  • Create a page listing the posts with a specific tag

As we have seen already with the categories, this is actually quite simple. First, we’ll sort out the tags on the index page:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
{% for post in object_list %}
<div class="post">
<h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text|custom_markdown }}
</div>
<a href="{{ post.category.get_absolute_url }}">{{ post.category.name }}</a>
{% for tag in post.tags.all %}
<a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>
{% endfor %}
{% endfor %}
{% if page_obj.has_previous %}
<a href="/{{ page_obj.previous_page_number }}/">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a href="/{{ page_obj.next_page_number }}/">Next Page</a>
{% endif %}
{% endblock %}

This is quite simple. We retrieve all the tags with post.tags.all and loop through them. We then do basically the same for the individual post pages:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
<div class="post">
<h1>{{ object.title }}</h1>
<h3>{{ object.pub_date }}</h3>
{{ object.text|custom_markdown }}
<a href="{{ object.category.get_absolute_url }}">{{ object.category.name }}</a>
{% for tag in post.tags.all %}
<a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>
{% endfor %}
<h4>Comments</h4>
<div class="fb-comments" data-href="http://{{ post.site }}{{ post.get_absolute_url }}" data-width="470" data-num-posts="10"></div>
</div>
{% endblock %}

This should resolve two of our outstanding tests:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
..................F
======================================================================
FAIL: test_tag_page (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 714, in test_tag_page
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 19 tests in 5.440s
FAILED (failures=1)
Destroying test database for alias 'default'...

The final test is for the tag pages. As we saw with the categories, we can limit our querysets on specific pages. So we’ll extend the ListView generic view again to handle tags:

from django.shortcuts import render
from django.views.generic import ListView
from blogengine.models import Category, Post, Tag
# Create your views here.
class CategoryListView(ListView):
def get_queryset(self):
slug = self.kwargs['slug']
try:
category = Category.objects.get(slug=slug)
return Post.objects.filter(category=category)
except Category.DoesNotExist:
return Post.objects.none()
class TagListView(ListView):
def get_queryset(self):
slug = self.kwargs['slug']
try:
tag = Tag.objects.get(slug=slug)
return tag.post_set.all()
except Tag.DoesNotExist:
return Post.objects.none()

Note that here, Tag objects have access to their assigned Post objects - we just use post_set to refer to them and get all of the posts associated with that tag. Next we’ll add the URLconfs:

from django.conf.urls import patterns, url
from django.views.generic import ListView, DetailView
from blogengine.models import Post, Category, Tag
from blogengine.views import CategoryListView, TagListView
urlpatterns = patterns('',
# Index
url(r'^(?P<page>\d+)?/?$', ListView.as_view(
model=Post,
paginate_by=5,
)),
# Individual posts
url(r'^(?P<pub_date__year>\d{4})/(?P<pub_date__month>\d{1,2})/(?P<slug>[a-zA-Z0-9-]+)/?$', DetailView.as_view(
model=Post,
)),
# Categories
url(r'^category/(?P<slug>[a-zA-Z0-9-]+)/?$', CategoryListView.as_view(
paginate_by=5,
model=Category,
)),
# Tags
url(r'^tag/(?P<slug>[a-zA-Z0-9-]+)/?$', TagListView.as_view(
paginate_by=5,
model=Tag,
)),
)

We import the Tag model and the TagListView view, and use them to set up the tag page.

If we now run our tests again, they should pass:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
...................
----------------------------------------------------------------------
Ran 19 tests in 5.473s
OK
Destroying test database for alias 'default'...

Well done! Time to commit:

$ git add templates/ blogengine/
$ git commit -m 'Tags are now shown'

RSS Feed

For the final task today, we’ll implement an RSS feed for our posts. Django ships with a handy syndication framework that makes it easy to implement this kind of functionality.

As usual, we’ll create some tests first. In this case, we won’t be adding any new models, so we don’t need to test them. Instead we can jump straight into creating acceptance tests for our feed. For now we’ll just create one type of feed: a feed of all the blog posts. In a later instalment we’ll add feeds for categories and tags.

First of all, we’ll implement our test. Now, in order to test our feed, we need to have a solution in place for parsing an RSS feed. Django won’t do this natively, so we’ll install the feedparser Python module. Run the following commands:

$ pip install feedparser
$ pip freeze > requirements.txt

Once that’s done, feedparser should be available. You may wish to refer to the documentation as we go to help.

Let’s write our test for the RSS feed. First, we import feedparser near the top of the file:

import feedparser

Then we define a new class for our feed tests. Put this towards the end of the file - I put it just before the flat page tests:

class FeedTest(BaseAcceptanceTest):
def test_all_post_feed(self):
# Create the category
category = Category()
category.name = 'python'
category.description = 'The Python programming language'
category.save()
# Create the tag
tag = Tag()
tag.name = 'python'
tag.description = 'The Python programming language'
tag.save()
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create a post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.category = category
# Save it
post.save()
# Add the tag
post.tags.add(tag)
post.save()
# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Fetch the feed
response = self.client.get('/feeds/posts/')
self.assertEquals(response.status_code, 200)
# Parse the feed
feed = feedparser.parse(response.content)
# Check length
self.assertEquals(len(feed.entries), 1)
# Check post retrieved is the correct one
feed_post = feed.entries[0]
self.assertEquals(feed_post.title, post.title)
self.assertEquals(feed_post.description, post.text)

Run the tests and you’ll see something like this:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
..............F.....
======================================================================
FAIL: test_all_post_feed (blogengine.tests.FeedTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 781, in test_all_post_feed
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 20 tests in 6.743s
FAILED (failures=1)
Destroying test database for alias 'default'...

We’re getting a 404 error because the post feed isn’t implemented. So let’s implement it. We’re going to use Django’s syndication framework, which will make it easy, but we need to enable it. Open up django_tutorial_blog_ng/settings/py and add the following under INSTALLED_APPS:

    'django.contrib.syndication',

Next, we need to enable the URLconf for this RSS feed. Open up blogengine/urls.py and amend the import fromblogengine.views` near the top:

from blogengine.views import CategoryListView, TagListView, PostsFeed

Further down, add in the following code to define the URL for the feed:

# Post RSS feed
url(r'^feeds/posts/$', PostsFeed()),

Note that we imported the PostsFeed class, but that hasn’t yet been defined. So we need to do that. First of all, add this line near the top:

from django.contrib.syndication.views import Feed

This imports the syndication views - yes, they’re another generic view! Our PostsFeed class is going to inherit from Feed. Next, we define the class:

class PostsFeed(Feed):
title = "RSS feed - posts"
link = "feeds/posts/"
description = "RSS feed - blog posts"
def items(self):
return Post.objects.order_by('-pub_date')
def item_title(self, item):
return item.title
def item_description(self, item):
return item.text

This is fairly straightforward. We define our title, link, and description for the feed inside the class definition. We define the items method which sets what items are returned by the RSS feed. We also define the item_title and item_description methods.

Now, if we run our tests, they should pass:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test blogengine
Creating test database for alias 'default'...
....................
----------------------------------------------------------------------
Ran 20 tests in 5.933s
OK
Destroying test database for alias 'default'...

Let’s commit our changes:

$ git add blogengine/ django_tutorial_blog_ng/ requirements.txt
$ git commit -m 'RSS feed implemented'

And that’s enough for now. Don’t forget, you can get the code for this lesson by cloning the repository from Github and running git checkout lesson-4 to switch to this lesson.

Next time we’ll:

  • Implement a search system
  • Add feeds for categories and posts
  • Tidy up the user interface

Hope to see you then!

25th January 2014 11:38 am

My First Yeoman Generator

At work I use the Skeleton boilerplate a lot - my boss, who handles most of the design work, likes it and generally uses it for his designs. I’ve also been using Grunt a lot lately, so it was inevitable that I’d probably start to look for a Yeoman generator for working with it.

There was an existing Yeoman generator for Skeleton, but it didn’t really do what I wanted. I wanted something that:

  • Included jQuery and Modernizr
  • Automatically concatenates and minifies all the JavaScript and CSS
  • Will automatically rebuild on changes
  • Includes LiveReload and a development server
  • Includes automatic deployment via FTP

After looking through the documentation for Yeoman, it was actually quick and easy to throw together my own generator and put it up. It’s available here, and the GitHub repository is here.

Future plans for it include:

  • Adding auto-prefixing for CSS
  • Removing redundant CSS rules automatically
  • Possibly, alternate deployment methods

Frustratingly, NPM seems to be playing up at present - it’s not picking up the README file, and the Yeoman site isn’t pulling it in. Any idea why, anyone?

3rd January 2014 12:57 pm

Django Blog Tutorial - the Next Generation - Part 3

Hello again! In this instalment, we’re going to do the following:

  • Add support for flat pages
  • Add support for multiple authors
  • Add a third-party comment system

Flat pages

Django ships with a number of useful apps - we’ve already used the admin interface. The flat pages app is another very handy app that comes with Django, and we’ll use it to allow the blog author to create a handful of flat pages.

First of all, you’ll need to install the flatpages app. Edit the INSTALLED_APPS setting as follows:

INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'south',
'blogengine',
'django.contrib.sites',
'django.contrib.flatpages',
)

Note that we needed to enable the sites framework as well. You’ll also need to set the SITE_ID setting:

SITE_ID = 1

With that done, run python manage.py syncdb to create the required database tables. Now, let’s use the sqlall command to take a look at the database structure generated for the flat pages:

BEGIN;
CREATE TABLE "django_flatpage_sites" (
"id" integer NOT NULL PRIMARY KEY,
"flatpage_id" integer NOT NULL,
"site_id" integer NOT NULL REFERENCES "django_site" ("id"),
UNIQUE ("flatpage_id", "site_id")
)
;
CREATE TABLE "django_flatpage" (
"id" integer NOT NULL PRIMARY KEY,
"url" varchar(100) NOT NULL,
"title" varchar(200) NOT NULL,
"content" text NOT NULL,
"enable_comments" bool NOT NULL,
"template_name" varchar(70) NOT NULL,
"registration_required" bool NOT NULL
)
;
CREATE INDEX "django_flatpage_sites_872c4601" ON "django_flatpage_sites" ("flatpage_id");
CREATE INDEX "django_flatpage_sites_99732b5c" ON "django_flatpage_sites" ("site_id");
CREATE INDEX "django_flatpage_c379dc61" ON "django_flatpage" ("url");
COMMIT;

As mentioned previously, all models in Django have an id attribute by default. Each flat page also has a URL, title, and content.

Also note the separate django_flatpage_sites table, which maps sites to flat pages. Django can run multiple sites from the same web app, and so flat pages must be allocated to a specific site. This relationship is a many-to-many relationship, so one flat page can appear on more than one site.

The other fields are hidden by default in the admin and can be ignored. Let’s have a go with Django’s handy shell to explore the flatpage. Run python manage.py shell and you’ll be able to interact with your Django application interactively:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py shell
Python 2.7.6 (default, Nov 23 2013, 13:53:45)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from django.contrib.flatpages.models import *
>>> FlatPage
<class 'django.contrib.flatpages.models.FlatPage'>
>>> from django.contrib.sites.models import Site
>>> Site.objects.all()
[<Site: example.com>]

As you can see, flatpages is a Django app similar to the blogengine one, with its own models, as is sites. You can see that the FlatPage class is a model. We can create an instance of it and save it interactively:

>>> f = FlatPage()
>>> f.url = '/about/'
>>> f.title = 'About me'
>>> f.content = 'All about me'
>>> f.save()
>>> f.sites.add(Site.objects.all()[0])
>>> f.save()

Note that because the relationship between the site and the flat page is a many-to-many relationship, we need to save it first, then use the add method to add the site to the list of sites.

We can retrieve it:

>>> FlatPage.objects.all()
[<FlatPage: /about/ -- About me>]
>>> FlatPage.objects.all()[0]
<FlatPage: /about/ -- About me>
>>> FlatPage.objects.all()[0].title
u'About me'

This command is often handy for debugging problems with your models interactively. If you now run the server and visit the admin, you should notice that the Flatpages app is now visible there, and the ‘About me’ flat page is now shown in there.

Let’s also take a look at the SQL required for the Site model. Run python manage.py sqlall sites:

BEGIN;
CREATE TABLE "django_site" (
"id" integer NOT NULL PRIMARY KEY,
"domain" varchar(100) NOT NULL,
"name" varchar(50) NOT NULL
)
;
COMMIT;

Again, very simple - just a domain and a name.

So, now that we have a good idea of how the flat page system works, we can write a test for it. We don’t need to write unit tests for the model because Django already does that, but we do need to write an acceptance test to ensure we can create flat pages and they will be where we expect them to be. Add the following to the top of the test file:

from django.contrib.flatpages.models import FlatPage
from django.contrib.sites.models import Site

Now, before we write this test, there’s some duplication to resolve. We have two tests that subclass LiveServerTestCase, and both have the same method, setUp. We can save ourselves some hassle by creating a new class containing this method and having both these tests inherit from it. We’ll do that now because the flat page test can also be based on it. Create the following class just after PostTest:

class BaseAcceptanceTest(LiveServerTestCase):
def setUp(self):
self.client = Client()

Then remove the setUp method from each of the two tests based on LiveServerTestCase, and change their parent class to BaseAcceptanceTest:

class AdminTest(BaseAcceptanceTest):
class PostViewTest(BaseAcceptanceTest):

With that done, run the tests and they should pass. Commit your changes:

$ git add blogengine/tests.py django_tutorial_blog_ng/settings.py
$ git commit -m 'Added flatpages to installed apps'

Now we can get started in earnest on our test for the flat pages:

class FlatPageViewTest(BaseAcceptanceTest):
def test_create_flat_page(self):
# Create flat page
page = FlatPage()
page.url = '/about/'
page.title = 'About me'
page.content = 'All about me'
page.save()
# Add the site
page.sites.add(Site.objects.all()[0])
page.save()
# Check new page saved
all_pages = FlatPage.objects.all()
self.assertEquals(len(all_pages), 1)
only_page = all_pages[0]
self.assertEquals(only_page, page)
# Check data correct
self.assertEquals(only_page.url, '/about/')
self.assertEquals(only_page.title, 'About me')
self.assertEquals(only_page.content, 'All about me')
# Get URL
page_url = only_page.get_absolute_url()
# Get the page
response = self.client.get(page_url)
self.assertEquals(response.status_code, 200)
# Check title and content in response
self.assertTrue('About me' in response.content)
self.assertTrue('All about me' in response.content)

Let’s run our tests:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
......F..
======================================================================
FAIL: test_create_flat_page (blogengine.tests.FlatPageViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 272, in test_create_flat_page
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 9 tests in 2.760s
FAILED (failures=1)

We can see why it’s failed - in our flat page test, the status code is 404, indicating the page was not found. This just means we haven’t put flat page support into our URLconf. So let’s fix that:

from django.conf.urls import patterns, include, url
from django.contrib import admin
admin.autodiscover()
urlpatterns = patterns('',
# Examples:
# url(r'^$', 'django_tutorial_blog_ng.views.home', name='home'),
# url(r'^blog/', include('blog.urls')),
url(r'^admin/', include(admin.site.urls)),
# Blog URLs
url(r'', include('blogengine.urls')),
# Flat pages
url(r'', include('django.contrib.flatpages.urls')),
)

Let’s run our tests again:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
......E..
======================================================================
ERROR: test_create_flat_page (blogengine.tests.FlatPageViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 276, in test_create_flat_page
response = self.client.get(page_url)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/test/client.py", line 473, in get
response = super(Client, self).get(path, data=data, **extra)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/test/client.py", line 280, in get
return self.request(**r)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/test/client.py", line 444, in request
six.reraise(*exc_info)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/core/handlers/base.py", line 114, in get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/contrib/flatpages/views.py", line 45, in flatpage
return render_flatpage(request, f)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/utils/decorators.py", line 99, in _wrapped_view
response = view_func(request, *args, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/contrib/flatpages/views.py", line 60, in render_flatpage
t = loader.get_template(DEFAULT_TEMPLATE)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/template/loader.py", line 138, in get_template
template, origin = find_template(template_name)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/template/loader.py", line 131, in find_template
raise TemplateDoesNotExist(name)
TemplateDoesNotExist: flatpages/default.html
----------------------------------------------------------------------
Ran 9 tests in 3.557s
FAILED (errors=1)
Destroying test database for alias 'default'...

Our test still fails, but we can easily see why - the template flatpages/default.html doesn’t exist. So we create it:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
<div class="post">
<h1>{{ flatpage.title }}</h1>
{{ flatpage.content|custom_markdown }}
</div>
{% endblock %}

This template is based on the blog post one, and just changes a handful of variable names. Note that it can still inherit from the blogengine base template, and in this case we’re using that for the sake of consistency.

If you run your tests, you should now see that they pass, so we’ll commit our changes:

$ git add templates/ django_tutorial_blog_ng/ blogengine/
$ git commit -m 'Implemented flat page support'

Multiple authors

Next we’ll add support for multiple authors. Now, Django already has a User model, and we’ll leverage that to represent the authors. But first we’ll write our test:

from django.test import TestCase, LiveServerTestCase, Client
from django.utils import timezone
from blogengine.models import Post
from django.contrib.flatpages.models import FlatPage
from django.contrib.sites.models import Site
from django.contrib.auth.models import User
import markdown
# Create your tests here.
class PostTest(TestCase):
def test_create_post(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the post
post = Post()
# Set the attributes
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
# Save it
post.save()
# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Check attributes
self.assertEquals(only_post.title, 'My first post')
self.assertEquals(only_post.text, 'This is my first blog post')
self.assertEquals(only_post.slug, 'my-first-post')
self.assertEquals(only_post.pub_date.day, post.pub_date.day)
self.assertEquals(only_post.pub_date.month, post.pub_date.month)
self.assertEquals(only_post.pub_date.year, post.pub_date.year)
self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
self.assertEquals(only_post.pub_date.second, post.pub_date.second)
self.assertEquals(only_post.author.username, 'testuser')
self.assertEquals(only_post.author.email, 'user@example.com')
class BaseAcceptanceTest(LiveServerTestCase):
def setUp(self):
self.client = Client()
class AdminTest(BaseAcceptanceTest):
fixtures = ['users.json']
def test_login(self):
# Get login page
response = self.client.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
# Log the user in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
def test_logout(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
# Log out
self.client.logout()
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
def test_create_post(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)
# Create the new post
response = self.client.post('/admin/blogengine/post/add/', {
'title': 'My first post',
'text': 'This is my first post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-first-post'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new post now in database
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
def test_edit_post(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the post
response = self.client.post('/admin/blogengine/post/1/', {
'title': 'My second post',
'text': 'This is my second blog post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-second-post'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post.title, 'My second post')
self.assertEquals(only_post.text, 'This is my second blog post')
def test_delete_post(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the post
response = self.client.post('/admin/blogengine/post/1/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 0)
class PostViewTest(BaseAcceptanceTest):
def test_index(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Fetch the index
response = self.client.get('/')
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
def test_post_page(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the post URL
post_url = only_post.get_absolute_url()
# Fetch the post
response = self.client.get(post_url)
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
class FlatPageViewTest(BaseAcceptanceTest):
def test_create_flat_page(self):
# Create flat page
page = FlatPage()
page.url = '/about/'
page.title = 'About me'
page.content = 'All about me'
page.save()
# Add the site
page.sites.add(Site.objects.all()[0])
page.save()
# Check new page saved
all_pages = FlatPage.objects.all()
self.assertEquals(len(all_pages), 1)
only_page = all_pages[0]
self.assertEquals(only_page, page)
# Check data correct
self.assertEquals(only_page.url, '/about/')
self.assertEquals(only_page.title, 'About me')
self.assertEquals(only_page.content, 'All about me')
# Get URL
page_url = str(only_page.get_absolute_url())
# Get the page
response = self.client.get(page_url)
self.assertEquals(response.status_code, 200)
# Check title and content in response
self.assertTrue('About me' in response.content)
self.assertTrue('All about me' in response.content)

Here we create a User object to represent the author. Note the create_user convenience method for creating new users quickly and easily.

We’re going to exclude the author field from the admin - instead it’s going to be automatically populated based on the session data, so that when a user creates a post they are automatically set as the author. We therefore don’t need to make any changes for the acceptance tests for posts - our changes to the unit tests for the Post model are sufficient.

Run the tests, and they should fail:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
E........
======================================================================
ERROR: test_create_post (blogengine.tests.PostTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 45, in test_create_post
self.assertEquals(only_post.author.username, 'testuser')
AttributeError: 'Post' object has no attribute 'author'
----------------------------------------------------------------------
Ran 9 tests in 3.620s
FAILED (errors=1)
Destroying test database for alias 'default'...

Let’s add the missing author attribute:

from django.db import models
from django.contrib.auth.models import User
# Create your models here.
class Post(models.Model):
title = models.CharField(max_length=200)
pub_date = models.DateTimeField()
text = models.TextField()
slug = models.SlugField(max_length=40, unique=True)
author = models.ForeignKey(User)
def get_absolute_url(self):
return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
def __unicode__(self):
return self.title
class Meta:
ordering = ["-pub_date"]

Next, create the migrations:

$ python manage.py schemamigration --auto blogengine

You’ll be prompted to either quit or provide a default author ID - select option 2 to provide the ID, then enter 1, which should be your own user account ID. Then run the migrations:

$ python manage.py migrate

Let’s run our tests again:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
.F.F.....
======================================================================
FAIL: test_create_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 118, in test_create_post
self.assertTrue('added successfully' in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_edit_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 154, in test_edit_post
self.assertTrue('changed successfully' in response.content)
AssertionError: False is not true
----------------------------------------------------------------------
Ran 9 tests in 3.390s
FAILED (failures=2)
Destroying test database for alias 'default'...

Our test still fails because the author field isn’t set automatically. So we’ll amend the admin to automatically set the author when the Post object is saved:

import models
from django.contrib import admin
from django.contrib.auth.models import User
class PostAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("title",)}
exclude = ('author',)
def save_model(self, request, obj, form, change):
obj.author = request.user
obj.save()
admin.site.register(models.Post, PostAdmin)

This tells the admin to exclude the author field from any form for a post, and when the model is saved, to set the author to the user making the HTTP request. Now run the tests, and they should pass:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
.........
----------------------------------------------------------------------
Ran 9 tests in 4.086s
OK
Destroying test database for alias 'default'...

Time to commit again:

$ git add blogengine/
$ git commit -m 'Added author field'

Comments

The previous version of this tutorial implemented comments using Django’s own comment system. However, this has since been deprecated from Django and turned into a separate project. So we have two options for how to implement comments:

Now, if you want to use the Django comment system, you can do so, and it shouldn’t be too hard to puzzle out how to implement it using the documentation and my prior post. However, in my humble opinion, using a third-party comment system is the way to go for blog comments - they make it extremely easy for people to log in with multiple services without you having to write lots of additional code. They also make it significantly easier to moderate comments, and they’re generally pretty good at handling comment spam.

Some of the available providers include:

For demonstration purposes, we’ll use Facebook comments, but this shouldn’t require much work to adapt it to the other providers.

First of all, we need to include the Facebook JavaScript SDK:

<!-- Add your site or application content here -->
<div id="fb-root"></div>
<script>(function(d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
js = d.createElement(s); js.id = id;
js.src = "//connect.facebook.net/en_GB/all.js#xfbml=1";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>
<div class="navbar navbar-static-top navbar-inverse">
<div class="navbar-inner">
<div class="container">
<a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</a>
<a class="brand" href="/">My Django Blog</a>
<div class="nav-collapse collapse">
</div>
</div>
</div>
</div>

Now, the Facebook comment system requires that you pass through the absolute page URL when initialising the comments. At present we can’t do that without hard-coding the domain name in our template, which we want to avoid. So, we need to add a site field to each post to identify the site it’s associated with.

As usual, we update our tests first:

from django.test import TestCase, LiveServerTestCase, Client
from django.utils import timezone
from blogengine.models import Post
from django.contrib.flatpages.models import FlatPage
from django.contrib.sites.models import Site
from django.contrib.auth.models import User
import markdown
# Create your tests here.
class PostTest(TestCase):
def test_create_post(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
# Set the attributes
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
# Save it
post.save()
# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Check attributes
self.assertEquals(only_post.title, 'My first post')
self.assertEquals(only_post.text, 'This is my first blog post')
self.assertEquals(only_post.slug, 'my-first-post')
self.assertEquals(only_post.site.name, 'example.com')
self.assertEquals(only_post.site.domain, 'example.com')
self.assertEquals(only_post.pub_date.day, post.pub_date.day)
self.assertEquals(only_post.pub_date.month, post.pub_date.month)
self.assertEquals(only_post.pub_date.year, post.pub_date.year)
self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
self.assertEquals(only_post.pub_date.second, post.pub_date.second)
self.assertEquals(only_post.author.username, 'testuser')
self.assertEquals(only_post.author.email, 'user@example.com')
class BaseAcceptanceTest(LiveServerTestCase):
def setUp(self):
self.client = Client()
class AdminTest(BaseAcceptanceTest):
fixtures = ['users.json']
def test_login(self):
# Get login page
response = self.client.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
# Log the user in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
def test_logout(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
# Log out
self.client.logout()
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
def test_create_post(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)
# Create the new post
response = self.client.post('/admin/blogengine/post/add/', {
'title': 'My first post',
'text': 'This is my first post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-first-post',
'site': '1'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new post now in database
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
def test_edit_post(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the post
response = self.client.post('/admin/blogengine/post/1/', {
'title': 'My second post',
'text': 'This is my second blog post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-second-post',
'site': '1'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post.title, 'My second post')
self.assertEquals(only_post.text, 'This is my second blog post')
def test_delete_post(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.site = site
post.author = author
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the post
response = self.client.post('/admin/blogengine/post/1/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 0)
class PostViewTest(BaseAcceptanceTest):
def test_index(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Fetch the index
response = self.client.get('/')
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
def test_post_page(self):
# Create the author
author = User.objects.create_user('testuser', 'user@example.com', 'password')
author.save()
# Create the site
site = Site()
site.name = 'example.com'
site.domain = 'example.com'
site.save()
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.author = author
post.site = site
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the post URL
post_url = only_post.get_absolute_url()
# Fetch the post
response = self.client.get(post_url)
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
class FlatPageViewTest(BaseAcceptanceTest):
def test_create_flat_page(self):
# Create flat page
page = FlatPage()
page.url = '/about/'
page.title = 'About me'
page.content = 'All about me'
page.save()
# Add the site
page.sites.add(Site.objects.all()[0])
page.save()
# Check new page saved
all_pages = FlatPage.objects.all()
self.assertEquals(len(all_pages), 1)
only_page = all_pages[0]
self.assertEquals(only_page, page)
# Check data correct
self.assertEquals(only_page.url, '/about/')
self.assertEquals(only_page.title, 'About me')
self.assertEquals(only_page.content, 'All about me')
# Get URL
page_url = str(only_page.get_absolute_url())
# Get the page
response = self.client.get(page_url)
self.assertEquals(response.status_code, 200)
# Check title and content in response
self.assertTrue('About me' in response.content)
self.assertTrue('All about me' in response.content)

All we’ve done here is to add the site attribute when creating a new post using the Django database API, and when we create one via the admin, we add an additional site aparameter to the HTTP POST request with a value of 1. Run the tests and they should fail:

Creating test database for alias 'default'...
E........
======================================================================
ERROR: test_create_post (blogengine.tests.PostTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 46, in test_create_post
self.assertEquals(only_post.site.name, 'example.com')
AttributeError: 'Post' object has no attribute 'site'
----------------------------------------------------------------------
Ran 9 tests in 4.313s
FAILED (errors=1)
Destroying test database for alias 'default'...

So we need to add the site attribute to the Post model. Let’s do that:

from django.db import models
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
# Create your models here.
class Post(models.Model):
title = models.CharField(max_length=200)
pub_date = models.DateTimeField()
text = models.TextField()
slug = models.SlugField(max_length=40, unique=True)
author = models.ForeignKey(User)
site = models.ForeignKey(Site)
def get_absolute_url(self):
return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
def __unicode__(self):
return self.title
class Meta:
ordering = ["-pub_date"]

Now create and run the migrations - you’ll be prompted to create a default value for the site attribute as well:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py schemamigration --auto blogengine
? The field 'Post.site' does not have a default specified, yet is NOT NULL.
? Since you are adding this field, you MUST specify a default
? value to use for existing rows. Would you like to:
? 1. Quit now, and add a default to the field in models.py
? 2. Specify a one-off value to use for existing columns now
? Please select a choice: 2
? Please enter Python code for your one-off default value.
? The datetime module is available, so you can do e.g. datetime.date.today()
>>> 1
+ Added field site on blogengine.Post
Created 0005_auto__add_field_post_site.py. You can now apply this migration with: ./manage.py migrate blogengine
(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py migrate
Running migrations for blogengine:
- Migrating forwards to 0005_auto__add_field_post_site.
> blogengine:0005_auto__add_field_post_site
- Loading initial data for blogengine.
Installed 0 object(s) from 0 fixture(s)

Our tests should then pass:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
.........
----------------------------------------------------------------------
Ran 9 tests in 4.261s
OK
Destroying test database for alias 'default'...

Now we can include our full page URL on the post detail page:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
<div class="post">
<h1>{{ object.title }}</h1>
<h3>{{ object.pub_date }}</h3>
{{ object.text|custom_markdown }}
<h4>Comments</h4>
<div class="fb-comments" data-href="http://{{ post.site }}{{ post.get_absolute_url }}" data-width="470" data-num-posts="10"></div>
</div>
{% endblock %}

If you want to customise the comments, take a look at the documentation for Facebook Comments.

With that done, we can commit our changes:

$ git add blogengine/ templates/
$ git commit -m 'Implemented Facebook comments'

And that wraps up this lesson. As usual, you can easily switch to today’s lesson with git checkout lesson-3. Next time we’ll implement categories and tags, and create an RSS feed for our blog posts.

2nd January 2014 11:28 am

Django Blog Tutorial - the Next Generation - Part 2

Welcome back! In this lesson, we’ll use Twitter Bootstrap to make our blog look nicer, and we’ll implement individual pages for each post.

Now, before we get started, don’t forget to switch into your virtualenv. From within the directory for the project, run the following command:

$ source venv/bin/activate

If you haven’t used Bootstrap before, you’re in for a treat. With Bootstrap, it’s easy to make a good-looking website quickly that’s responsive and mobile-friendly. We’ll also use HTML5 Boilerplate to get a basic HTML template in place.

Now, to install these easily, we’ll use Bower, which requires Node.js. Install Node.js first. On most Linux distros, you’ll also need to set NODE_PATH, which can be done by pasting the following into your .bashrc:

NODE_PATH="/usr/local/lib/node_modules"

With that done, run the following command to install Bower:

$ sudo npm install -g bower

Next we need to create a Bower config. First, create the folder blogengine/static. Then create a new file called .bowerrc and paste in the following content:

{
"directory": "blogengine/static/bower_components"
}

This tells Bower where it should put downloaded libraries. Next, run the following command to gener Bower:

$ bower init

Answer all the questions it asks you - for those with defaults, these should be fine, and everything else should be easy enough. Next, run the following command to install Bootstrap and HTML5 Boilerplate:

$ bower install bootstrap html5-boilerplate --save

Note that as jQuery is a dependency of Bootstrap, it will also be installed automatically. Now, we need to keep our Bower-installed files out of version control - the bower.json file keeps track of them for us. So add the following to your .gitignore file:

blogengine/static/bower_components/

All done? Let’s commit our changes:

$ git add .gitignore .bowerrc bower.json
$ git commit -m 'Added Bower config'

Now, let’s make our template nicer. Django’s templating system is very powerful and lets one template inherit from another. We’re going to create a base template, using HTML5 Boilerplate as a starting point, that all of our web-facing pages will use. First, create a directory to hold the base template:

$ mkdir templates/blogengine/includes

Then copy the index.html file from HTML5 Boilerplate to this directory as base.html:

$ cp blogengine/static/bower_components/html5-boilerplate/index.html templates/blogengine/includes/base.html

Now amend this file to look like this:

<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{% block title %}My Django Blog{% endblock %}</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
{% load staticfiles %}
<link rel="stylesheet" href="{% static 'bower_components/html5-boilerplate/css/normalize.css' %}">
<link rel="stylesheet" href="{% static 'bower_components/html5-boilerplate/css/main.css' %}">
<link rel="stylesheet" href="{% static 'bower_components/bootstrap/dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'bower_components/bootstrap/dist/css/bootstrap-theme.min.css' %}">
<script src="{% static 'bower_components/html5-boilerplate/js/vendor/modernizr-2.6.2.min.js' %}"></script>
</head>
<body>
<!--[if lt IE 7]>
<p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
<![endif]-->
<!-- Add your site or application content here -->
{% block content %}{% endblock %}
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
<script>window.jQuery || document.write('<script src="{% static 'bower_components/html5-boilerplate/js/vendor/jquery-1.10.2.min.js' %}"><\/script>')</script>
<script src="{% static 'bower_components/html5-boilerplate/js/plugins.js' %}"></script>
<script src="{% static 'bower_components/bootstrap/dist/js/bootstrap.min.js' %}"></script>
<!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
<script>
(function(b,o,i,l,e,r){b.GoogleAnalyticsObject=l;b[l]||(b[l]=
function(){(b[l].q=b[l].q||[]).push(arguments)});b[l].l=+new Date;
e=o.createElement(i);r=o.getElementsByTagName(i)[0];
e.src='//www.google-analytics.com/analytics.js';
r.parentNode.insertBefore(e,r)}(window,document,'script','ga'));
ga('create','UA-XXXXX-X');ga('send','pageview');
</script>
</body>
</html>

Note the following:

  • We need to use {% load staticfiles %} to be able to load any static files.
  • We use the {% static %} template tag to load static files such as CSS and HTML
  • We define blocks called title and content. Any template that extends this one can override whatever is inside this template.

Please note that HTML5 Boilerplate may conceivable change in future, so bear in mind that all you really need to do is load the staticfiles app, use the static tag for any static files that need to be loaded, and define the blocks in the appropriate places.

Next, let’s amend our existing template to inherit from this one:

{% extends "blogengine/includes/base.html" %}
{% block content %}
{% for post in object_list %}
<h1>{{ post.title }}</h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text }}
{% endfor %}
{% endblock %}

Now fire up the server with python manage.py runserver and check everything is working OK. You should see that your new base template is now in use and the CSS and JS files are being loaded correctly. Let’s commit again:

$ git add templates/
$ git commit -m 'Now use Bootstrap and HTML5 Boilerplate for templates'

Now, let’s use Bootstrap to style our blog a little. First we’ll add a navigation bar at the top of our blog. Edit the base template as follows:

<div class="navbar navbar-static-top navbar-inverse">
<div class="navbar-inner">
<div class="container">
<a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</a>
<a class="brand" href="/">My Django Blog</a>
<div class="nav-collapse collapse">
</div>
</div>
</div>
</div>
<div class="container">
{% block header %}
<div class="page-header">
<h1>My Django Blog</h1>
</div>
{% endblock %}
<div class="row">
{% block content %}{% endblock %}
</div>
</div>
<div class="container footer">
<div class="row">
<div class="span12">
<p>Copyright &copy; {% now "Y" %}</p>
</div>
</div>
</div>

Note the footer copyright section. Here we output the current year using now. Also note the addition of the header block. This will let us override the page header if necessary.

We’ll also wrap the posts in a div:

{% extends "blogengine/includes/base.html" %}
{% block content %}
{% for post in object_list %}
<div class="post">
<h1>{{ post.title }}</h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text }}
</div>
{% endfor %}
{% endblock %}

Let’s commit our changes:

$ git add templates/
$ git commit -m 'Amended templates'

Formatting our content

As it stands right now, we can’t do much to format our posts. It is possible to include HTML in our posts with Django, but by default it will strip it out. Also, we don’t want users to have to write HTML manually - we want to make our blog user friendly!

There are two possible approaches. One is to embed a rich text editor like TinyMCE in the admin and use that for editing the files, but I’ve found things like that to be cumbersome. The alternative is to use some other form of lightweight markup, and that’s the approach we’ll take here. We’re going to use Markdown for editing our posts.

Django has actually dropped support for Markdown, but it’s not hard to implement your own version. First, install Markdown and add it to your requirements.txt:

$ pip install markdown
$ pip freeze > requirements.txt

Now, we shouldn’t write any production code before writing a test, so let’s amend our existing post test to check to see that Markdown is working as expected:

class PostViewTest(LiveServerTestCase):
def setUp(self):
self.client = Client()
def test_index(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.pub_date = timezone.now()
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Fetch the index
response = self.client.get('/')
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)

You’ll also need to add the following at the top:

import markdown

What we do here is we convert our post text to include a link using Markdown. We also need to render that post in markdown within the test so that what we have in the test matches what will be produced - otherwise our test will be broken. We also check that the link is marked up correctly.

Save the file and run the tests - they should fail. Now, create the following directory and file:

$ mkdir blogengine/templatetags
$ touch blogengine/templatetags/__init__.py

Note that the __init__.py file is meant to be blank.

Then create the following file and edit it to look like this:

import markdown
from django import template
from django.template.defaultfilters import stringfilter
from django.utils.encoding import force_unicode
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter(is_safe=True)
@stringfilter
def custom_markdown(value):
extensions = ["nl2br", ]
return mark_safe(markdown.markdown(force_unicode(value),
extensions,
safe_mode=True,
enable_attributes=False))

Then just amend the post list template to use it:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
{% for post in object_list %}
<div class="post">
<h1>{{ post.title }}</h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text|custom_markdown }}
</div>
{% endfor %}
{% endblock %}

It’s that easy to use a custom markup system with your blog!

Let’s commit the changes:

$ git add requirements.txt templates/ blogengine/
$ git commit -m 'Added Markdown support'

Pagination

As at right now, all of our posts are displayed on the index page. We want to fix that by implementing pagination. Fortunately, that’s very easy for us because we’re using Django’s generic views. Go into blogengine/urls.py and amend it as follows:

from django.conf.urls import patterns, url
from django.views.generic import ListView
from blogengine.models import Post
urlpatterns = patterns('',
# Index
url(r'^(?P<page>\d+)?/?$', ListView.as_view(
model=Post,
paginate_by=5,
)),
)

That will automatically paginate our posts by 5 - feel free to change the value of paginate_by if you wish. However, we need to place the links in our template as well:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
{% for post in object_list %}
<div class="post">
<h1>{{ post.title }}</h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text|custom_markdown }}
</div>
{% endfor %}
{% if page_obj.has_previous %}
<a href="/{{ page_obj.previous_page_number }}/">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a href="/{{ page_obj.next_page_number }}/">Next Page</a>
{% endif %}
{% endblock %}

Try adding a few more blog posts, and you’ll see the pagination links. But give them a try, and they won’t work. Why not? Well, as it turns out there was a bug in the project-wide urls.py file (my bad!). Let’s fix that:

from django.conf.urls import patterns, include, url
from django.contrib import admin
admin.autodiscover()
urlpatterns = patterns('',
# Examples:
# url(r'^$', 'django_tutorial_blog_ng.views.home', name='home'),
# url(r'^blog/', include('blog.urls')),
url(r'^admin/', include(admin.site.urls)),
# Blog URLs
url(r'', include('blogengine.urls')),
)

If you try again, you’ll see that the blogengine app now happily deals with the paginated posts. Let’s commit our changes:

$ git add blogengine/ django_tutorial_blog_ng/ templates/
$ git commit -m 'Implemented pagination'

Viewing individual posts

As our last task for today, we’ll implement individual pages for each post. We want each post to have a nice, friendly URL that is as human-readable as possible, and also includes the date the post was created.

First of all, we’ll implement our test for it, however:

def test_post_page(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.pub_date = timezone.now()
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the post URL
post_url = only_post.get_absolute_url()
# Fetch the post
response = self.client.get(post_url)
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)

Add this method to the PostViewTest class, after test_index. It’s very similar to test_index, since it’s testing much the same content. However, not that we fetch the post-specific URL using the method get_absolute_url, and we then fetch that page.

Now, if you run the test, it will fail because get_absolute_url isn’t implemented. It’s often a good idea to have a get_absolute_url method for your models, which defines a single URL scheme for that type of object. So let’s create one. However, to implement our URL scheme we need to make some changes. Right now we have the date, but we don’t have a text string we can use, known in Django as a slug. So we’ll add a slug field, which will be prepopulated based on the post title. Edit your model as follows:

from django.db import models
# Create your models here.
class Post(models.Model):
title = models.CharField(max_length=200)
pub_date = models.DateTimeField()
text = models.TextField()
slug = models.SlugField(max_length=40, unique=True)
def get_absolute_url(self):
return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
def __unicode__(self):
return self.title
class Meta:
ordering = ["-pub_date"]

Here we’ve added a slug field to the model, as well as implementing our get_absolute_url method. Note we’ve limited the date to year and month, but you can include days if you wish.

While we’re in here, we’ve also implemented the __unicode__ method. Essentially, this sets how Django describes the object in the admin - in this case, the post title is a logical way of describing that Post object, so it returns the post title.

We’ve also added the class Meta, with the ordering field. This tells Django that by default any list of posts should return them ordered by pub_date in reverse - in other words, latest first.

To have the slug filled in automatically, we need to customise the admin interface a little as well:

import models
from django.contrib import admin
class PostAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("title",)}
admin.site.register(models.Post, PostAdmin)

Now, I recommend at this stage going into the admin and deleting all of your posts, because otherwise you’ll have problems in migrating them. The issue is that each slug is compulsory and must be unique, and it’s not practical to use South to automatically generate new slugs from the title on the fly, so by deleting them at this stage you’ll avoid problems. Once that’s done, run this command:

$ python manage.py schemamigration --auto blogengine

You’ll be prompted to specify a one-off default value - enter any string you like, such as “blah”. Then run the migration:

$ python manage.py migrate

Let’s run our tests now:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
.F.F...F
======================================================================
FAIL: test_create_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 103, in test_create_post
self.assertTrue('added successfully' in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_edit_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 132, in test_edit_post
self.assertTrue('changed successfully' in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_post_page (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 222, in test_post_page
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
----------------------------------------------------------------------
Ran 8 tests in 2.180s
FAILED (failures=3)
Destroying test database for alias 'default'...

Whoops! Our tests are broken, because the slug field isn’t being filled in. If you take a look at the page for adding a post, you’ll notice that the slug is filled in using JavaScript, so our test fails because the test client doesn’t interpret JavaScript. So in the tests we have to fill in the slug field manually.

Also, for the unit tests, the slug attribute isn’t being created at all, so it can’t be saved. Let’s remedy that. First, edit the test_create_post method of PostTest:

class PostTest(TestCase):
def test_create_post(self):
# Create the post
post = Post()
# Set the attributes
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
# Save it
post.save()
# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Check attributes
self.assertEquals(only_post.title, 'My first post')
self.assertEquals(only_post.text, 'This is my first blog post')
self.assertEquals(only_post.slug, 'my-first-post')
self.assertEquals(only_post.pub_date.day, post.pub_date.day)
self.assertEquals(only_post.pub_date.month, post.pub_date.month)
self.assertEquals(only_post.pub_date.year, post.pub_date.year)
self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
self.assertEquals(only_post.pub_date.second, post.pub_date.second)

Next, let’s amend AdminTest:

class AdminTest(LiveServerTestCase):
fixtures = ['users.json']
def setUp(self):
self.client = Client()
def test_login(self):
# Get login page
response = self.client.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
# Log the user in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
def test_logout(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
# Log out
self.client.logout()
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
def test_create_post(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)
# Create the new post
response = self.client.post('/admin/blogengine/post/add/', {
'title': 'My first post',
'text': 'This is my first post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-first-post'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new post now in database
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
def test_edit_post(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the post
response = self.client.post('/admin/blogengine/post/1/', {
'title': 'My second post',
'text': 'This is my second blog post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-second-post'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post.title, 'My second post')
self.assertEquals(only_post.text, 'This is my second blog post')
def test_delete_post(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the post
response = self.client.post('/admin/blogengine/post/1/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 0)

And PostViewTest:

class PostViewTest(LiveServerTestCase):
def setUp(self):
self.client = Client()
def test_index(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Fetch the index
response = self.client.get('/')
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
def test_post_page(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
post.slug = 'my-first-post'
post.pub_date = timezone.now()
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Get the post URL
post_url = only_post.get_absolute_url()
# Fetch the post
response = self.client.get(post_url)
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(markdown.markdown(post.text) in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)
# Check the link is marked up properly
self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)

What we’re doing here is that every time we create a Post object programmatically, we add the post.slug atttribute to it. Also, when submitting a post via the admin, we pass the slug parameter via HTTP POST, thus emulating how a form would submit this data.

If you run the tests again, you’ll see that test_post_page still fails. This is because we haven’t yet up the URLs, templates and views to do so. Let’s fix that. We’ll use another generic view, called a DetailView, to display the posts. Amend blogengine/urls.py as follows:

rom django.conf.urls import patterns, url
from django.views.generic import ListView, DetailView
from blogengine.models import Post
urlpatterns = patterns('',
# Index
url(r'^(?P<page>\d+)?/?$', ListView.as_view(
model=Post,
paginate_by=5,
)),
# Individual posts
url(r'^(?P<pub_date__year>\d{4})/(?P<pub_date__month>\d{1,2})/(?P<slug>[a-zA-Z0-9-]+)/?$', DetailView.as_view(
model=Post,
)),
)

Running our tests again will still fail, but now because the template post_detail.html has not been found. So let’s create it:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
<div class="post">
<h1>{{ object.title }}</h1>
<h3>{{ object.pub_date }}</h3>
{{ object.text|custom_markdown }}
</div>
{% endblock %}

If you run your tests again, they should now pass. However, we still need to provide a hyperlink from each post in the index to the post page, so let’s do that:

{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
{% for post in object_list %}
<div class="post">
<h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text|custom_markdown }}
</div>
{% endfor %}
{% if page_obj.has_previous %}
<a href="/{{ page_obj.previous_page_number }}/">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a href="/{{ page_obj.next_page_number }}/">Next Page</a>
{% endif %}
{% endblock %}

And that’s all for today! We now have individual post pages, we’ve styled our blog a bit, and we’ve implemented Markdown support. All that remains is to commit our changes:

$ git add blogengine/ templates/
$ git commit -m 'Implemented post pages'

As before, I’ve tagged the final commit with ‘lesson-2’, so if you’re following along, you can switch to this point with git checkout lesson-2.

Next time we’ll add support for flat pages and multiple authors, as well as adding support for comments via a third-party commenting system.

28th December 2013 3:00 pm

Django Blog Tutorial - the Next Generation - Part 1

My series of Django tutorials for building a blogging engine are by far the most popular posts I’ve ever written on here. I’ve had a lot of people contact me with questions or just to express their thanks, for which I’m very grateful!

However, these tutorials haven’t really aged well. I’ve since had the opportunity to use Django in a professional capacity, which has significantly improved my understanding of the framework and the whole Python ecosystem, and there’s a lot of best practices that I didn’t follow and now wish I had. There’s also been a few gotchas that have hindered a few people in the past that I’d like to have the opportunity to correct.

So, I’m therefore creating a brand new series of tutorials to remedy this situation. This series will cover exactly the same basic idea of using Django to build a blogging engine, but will expand on what the original series did in many ways. We will cover such additional topics as:

  • Using Twitter Bootstrap to make your blog look good without too much hassle
  • Using Virtualenv to sandbox your blog application
  • Using South to effectively manage changes to your database structure
  • Writing some simple unit tests
  • Deploying the finished application to Heroku

Ready? Let’s get started!

Getting everything set up

Now, first of all, I’m going to assume you’re using some variant of Unix, such as Linux or Mac OS X. I’m not saying you can’t follow this tutorial with Windows, but you’ll have a harder time, because Windows just isn’t as developer-friendly as Unix in general. A modern Linux distro like Ubuntu is generally pretty easy to use, and you can easily run it in Virtualbox, so if you use Windows I would recommend you use that to make things easier.

You should also have at least a basic grasp of the command line, such as how to create and navigate directories. You don’t have to be a wizard with it, though.

You also need a proper programmer’s text editor. I use Vim, but I freely admit that Vim has a very steep learning curve and you may have trouble picking it up at the same time as following this tutorial. Emacs is also a very powerful text editor, and if you like it, feel free to use it. If you haven’t yet found a programmer’s text editor you like, I suggest you check out Sublime Text, which is easy to get started with, but also very powerful, and can be used without purchasing a license. Don’t worry too much about your text editor - it’s not vitally import that you use what I use, just find one that works for you. That said, I will say one thing - DON’T use an IDE. IDE’s hide too many details from new users and make it harder to figure out what’s going on.

You will also need to ensure you have the following installed:

  • Python. I recommend installing Python 2.7, because you may have issues with Python 2.6, and Python 3 isn’t universally supported yet so you might have some issues with that
  • Virtualenv
  • Pip
  • Git

On most Linux distros, you can find packages for all of these items easily enough using your package manager. On Mac OS X, I recommend using Homebrew to install them, though if you have another package manager installed you can use that too. If you have issues with installing any of these, a quick Google search should be sufficient to resolve the issue.

Beginning work

With all that done, we’re ready to get started. Create a folder in a suitable place on your file system and switch into it. I generally keep a dedicated folder in my home directory called Projects to use for all of my projects, and give each project a folder within it - in this case the project is called django_tutorial_blog_ng.

Now, we’ll use Git to keep track of our source code. If you prefer Mercurial, feel free to use that, but this tutorial will assume use of Git, so you’ll want to adapt the commands used accordingly. Start tracking your project with the following command from the shell, when you’re in the project directory:

$ git init

If you haven’t used Git before, you’ll also want to configure it.

Next, we set up our virtualenv. Run the following command:

$ virtualenv venv --distribute

Followed by:

$ source venv/bin/activate

Every time you come back to work on this project, you’ll need to run the previous command to make sure you’re running the version of Python installed under venv/ rather than your system Python. You can tell it’s using this because your shell prompt will be prefixed with (venv).

Why do this? Well, it means you can install whatever version of a Python module you like, without having root access, and means the Python install you’re using will only have those modules you explicitly install, rather than all of those ones available with your operating system. For instance, you could have multiple projects using different versions of Django, rather than having to update a global installation of Django and potentially break existing applications.

Now that our virtualenv is set up, we’ll install Django, as well as several other useful Python modules. Run the following command:

$ pip install django-toolbelt South

A little explanation is called for here. The package django-toolbelt includes a number of packages we’ll be using, including Django, as well as Gunicorn (a simple web server we’ll use when the time comes to deploy the app to Heroku). South is a migration tool that is commonly used with Django - basically, if you make changes to existing models, Django doesn’t natively have the capacity to apply those changes (yet - native migrations are planned at some point in the future), so South can be used to apply those changes for you without having to either manually change the database structure or dump the database and rebuild it.

Please note that one of the packages, psycopg2, may fail if you don’t have PostgreSQL installed, but don’t worry about installing it. We’ll be using SQLite for developing the application locally, and we’ll be deploying the finished product to Heroku, which does have it installed.

Once the installation is complete, run the following command to record the new modules installed:

$ pip freeze > requirements.txt

The file requirements.txt will be created, which stores the packages and versions you have installed so that they can be easily recreated. If you had issues installing psycopg2, then here’s what your requirements.txt should look like - feel free to edit it manually to look like this, as when we deploy it to Heroku, it will need to be correct to ensure that our application can be deployed successfully:

Django==1.6.1
South==0.8.4
dj-database-url==0.2.2
dj-static==0.0.5
django-toolbelt==0.0.1
gunicorn==18.0
psycopg2==2.5.1
static==0.4
wsgiref==0.1.2

Next, we’ll commit these changes with Git:

$ git add requirements.txt
$ git commit -m 'Committed requirements'

Next we’ll add a .gitignore file to ignore our virtualenv - we want to keep this out of version control because it’s something specific to that install. We have all we need to recreate it so we don’t want to store it. In addition, we also want to ignore any compiled Python files (identifiable by the .pyc suffix):

venv/
*.pyc

Let’s commit that too:

$ git add .gitignore
$ git commit -m 'Added a gitignore file'

Now, let’s generate our project’s basic skeleton:

$ django-admin.py startproject django_tutorial_blog_ng .

This application skeleton includes a basic configuration which will be sufficient for now, but you will also want to add the SQLite database file to your .gitignore:

env/
*.pyc
db.sqlite3

Let’s commit what we’ve done:

$ git add .gitignore django_tutorial_blog_ng/ manage.py
$ git commit -m 'Created project skeleton'

Now, before we create our database, we need to ensure we are using South. Go into django_tutorial_blog_ng/settings.py and find INSTALLED_APPS. Edit it to look like this:

INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'south',
)

Now, you can create your database. Run the following command:

$ python manage.py syncdb

You’ll be prompted to create a superuser - go ahead and fill in the details. Now, run the following command:

$ python manage.py runserver

This will run Django’s built-in web server on port 8000, and if you click here, you should see a page congratulating you on your first Django-powered page. Once you’re finished with it, you can stop the web server with Ctrl-C.

Don’t forget to commit your changes:

$ git add django_tutorial_blog_ng/settings.py
$ git commit -m 'Added South to installed apps'

Your first app

Django distinguishes between the concepts of projects and apps. A project is a specific project that may consist of one or more apps, such as a web app, whereas an app is a set of functionality within a project. For instance, one website might include some flat pages, an admin interface, and a blogging engine, and these could easily be different apps. By encouraging you to separate different types of functionality into different apps, Django makes it easier for you to reuse existing content elsewhere.

We’re going to create our first app, which is the blogging engine. Run the following command to create a basic skeleton for this app:

$ python manage.py startapp blogengine

Next, we need to amend our settings to install this app:

INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'south',
'blogengine',
)

Now, before we can use this app, we want to let South know about it so that changes to your database structure will be managed right from the start by South. Run the following command to create your initial migration:

$ python manage.py schemamigration --initial blogengine

That creates the file for your first migration,but doesn’t run it. To migrate your database structure to the latest version, run the following:

$ python manage.py migrate

This won’t actually make any changes, but it will ensure that all future changes to your models for the blogengine app are handled by South. Let’s commit our app skeleton:

$ git add django_tutorial_blog_ng/settings.py blogengine/
$ git commit -m 'Added blogengine app skeleton'

So, we now have our first app set up, but it doesn’t do anything much.

Remember that I mentioned how Django differentiates between projects and apps? Well, Django actually ships with a number of useful apps, and one of those is the admin interface. I consider the Django admin to be one of the framework’s killer features because it’s easy to use and customise, and saves you a lot of grief.

In the past, the admin interface needed a little work to get it working, but in Django 1.6 it’s configured to work out of the box, so if you click here, you should see the login screen for it. You should be able to sign in using the username and password you set when you ran syncdb.

Next, we’ll set up our first model.

An introduction to MVC

MVC is a common pattern used in web development. Many web development frameworks can be loosely described as MVC, including Django, Rails, CodeIgniter, Laravel and Symfony, as well as some client-side frameworks like Backbone.js. The basic concept is that a web app is divided into three basic components:

  • Models - the data managed with the application
  • Views - the presentation of the data
  • Controllers - an intermediary between the models and the views

Now, Django’s interpretation of MVC is slightly different to many other frameworks. While in most frameworks the views are HTML templates for rendering the data, in Django this role is taken by the templates, and the views are functions or objects that render data from the models using a template. Effectively, you can think of Django’s views as being like controllers in other frameworks, and Django templates as being views.

In Django, you create your models as Python classes that represent your data, and you use the Django ORM to query the database. As a result, it’s rare to have to directly query your database using SQL, making it more portable between different databases.

Now, our first model is going to be of a blog post. At least initially, each post will have the following attributes:

  • A title
  • A publication date and time
  • Some text

Now, we could just jump straight into creating our first model, but we’re going to make a point of following the practices of test-driven development here. The basic concept of TDD is that you write a failing test before writing any code, then you write the code to pass that test afterwards. It does make things a bit slower, but it’s all too easy to neglect writing tests at all if you leave it till later.

If you take a look in the blogengine folder you’ll notice there’s a file called tests.py. Open it up and you should see the following:

from django.test import TestCase
# Create your tests here.

It’s worth taking a little time to plan out what we want to test from our post model. Each post object will have the attributes I mentioned above, and what we want to be able to do is test that we can:

  • Set the title
  • Set the publication date and time
  • Set the text
  • Save it successfully
  • Retrieve it successfully

So, let’s create a test for our post model. We’ll go through the relevant sections of the test bit by bit:

from django.test import TestCase
from django.utils import timezone
from blogengine.models import Post

Here we’re importing the required functionality. TestCase is provided by Django, and is an object all of your tests should inherit from. timezone is a utility for handling dates and times correctly. Finally, Post is our model, which we have yet to implement.

# Create your tests here.
class PostTest(TestCase):
def test_create_post(self):
# Create the post
post = Post()

Here we create the PostTest class, which represents a test for your Post model. So far it only has one method, but you can add additional ones if required.

# Set the attributes
post.title = 'My first post'
post.text = 'This is my first blog post'
post.pub_date = timezone.now()

Here we set the post’s attributes.

# Save it
post.save()

Now we save it. At this point it has been added to the database, and the rest of the test involves us ensuring it has been saved correctly and can be retrieved.

# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)

Here we use the Django database API to fetch all of the Post objects, assert that there is only 1 post object, retrieve that post object, and assert that it is the same object as the post object we just saved.

If unit testing is new to you, assertions may be new to you. Essentially you’re saying to the Python interpreter, “I assert that X is true, so please raise an error if this is not true”. Here we assert that the length of the variable all_posts is 1, and that that post is the same object as the previously saved object, so that the test will fail if that is not the case.

# Check attributes
self.assertEquals(only_post.title, 'My first post')
self.assertEquals(only_post.text, 'This is my first blog post')
self.assertEquals(only_post.pub_date.day, post.pub_date.day)
self.assertEquals(only_post.pub_date.month, post.pub_date.month)
self.assertEquals(only_post.pub_date.year, post.pub_date.year)
self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
self.assertEquals(only_post.pub_date.second, post.pub_date.second)

Finally, we assert that the values of each of the post’s attributes as stored in the database match up with those in the post object we set. For the title and text fields, these are easy to validate as we can just check the values against those we set. For the pub_date field, things are a bit more complex, since this will be an object in its own right, so you need to check the day, month, year, hour, minute and second attributes separately.

The whole thing should look like this:

from django.test import TestCase
from django.utils import timezone
from blogengine.models import Post
# Create your tests here.
class PostTest(TestCase):
def test_create_post(self):
# Create the post
post = Post()
# Set the attributes
post.title = 'My first post'
post.text = 'This is my first blog post'
post.pub_date = timezone.now()
# Save it
post.save()
# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Check attributes
self.assertEquals(only_post.title, 'My first post')
self.assertEquals(only_post.text, 'This is my first blog post')
self.assertEquals(only_post.pub_date.day, post.pub_date.day)
self.assertEquals(only_post.pub_date.month, post.pub_date.month)
self.assertEquals(only_post.pub_date.year, post.pub_date.year)
self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
self.assertEquals(only_post.pub_date.second, post.pub_date.second)

With that in place, the time has come to run our test with the following command:

$ python manage.py test

You should see something like this:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
E
======================================================================
ERROR: blogengine.tests (unittest.loader.ModuleImportFailure)
----------------------------------------------------------------------
ImportError: Failed to import test module: blogengine.tests
Traceback (most recent call last):
File "/usr/local/Cellar/python/2.7.6/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/loader.py", line 254, in _find_tests
module = self._get_module_from_name(name)
File "/usr/local/Cellar/python/2.7.6/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/loader.py", line 232, in _get_module_from_name
__import__(name)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 3, in <module>
from blogengine.models import Post
ImportError: cannot import name Post
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)
Destroying test database for alias 'default'...

Don’t worry about the error - this is exactly what we expect to see because we haven’t implemented our Post model yet. Now that we have a failing test in place, we can implement our model to make the test pass. Open up blogengine/models.py and enter the following:

from django.db import models
# Create your models here.
class Post(models.Model):
title = models.CharField(max_length=200)
pub_date = models.DateTimeField()
text = models.TextField()

Save the file and run the test again:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
E
======================================================================
ERROR: test_create_post (blogengine.tests.PostTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 17, in test_create_post
post.save()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 545, in save
force_update=force_update, update_fields=update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 573, in save_base
updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 654, in _save_table
result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/base.py", line 687, in _do_insert
using=using, raw=raw)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 232, in _insert
return insert_query(self.model, objs, fields, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 1511, in insert_query
return query.get_compiler(using=using).execute_sql(return_id)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/sql/compiler.py", line 898, in execute_sql
cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/utils.py", line 99, in __exit__
six.reraise(dj_exc_type, dj_exc_value, traceback)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/util.py", line 53, in execute
return self.cursor.execute(sql, params)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/backends/sqlite3/base.py", line 450, in execute
return Database.Cursor.execute(self, query, params)
OperationalError: no such table: blogengine_post
----------------------------------------------------------------------
Ran 1 test in 0.059s
FAILED (errors=1)
Destroying test database for alias 'default'...

Our test still fails, but if we take a look at this error message, we can see why - there is no database table for the posts (called blogengine_post). Using South, we can easily remedy that by creating a new migration to create this table:

$ python manage.py schemamigration --auto blogengine

That creates the new migration. Now let’s run it:

$ python manage.py migrate

Now, let’s run our tests to check it’s working as expected:

(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...

Success! We now have a model in place that passes our test. Let’s commit our changes:

$ git add blogengine/
$ git commit -m 'Added post model with passing test'

Now, Django’s ORM is basically a layer on top of SQL that abstracts away differences between different relational databases, but the underlying queries are still being run. You can view the SQL created to generate the table by using the sqlall command. Just run python manage.py sqlall blogengine and you should see something like this:

BEGIN;
CREATE TABLE "blogengine_post" (
"id" integer NOT NULL PRIMARY KEY,
"title" varchar(200) NOT NULL,
"pub_date" datetime NOT NULL,
"text" text NOT NULL
)
;
COMMIT;

Note the addition of the id field as the primary key. If you’re at all familiar with relational databases, you’ll know that every table must have one field, called a primary key, that is a unique reference to that row. This can be overridden, but here it’s exactly the behaviour we want.

Creating blog posts via the admin

Now, we need a way to be able to create, edit and delete blog posts. Django’s admin interface allows us to do so easily. However, before we do so, we want to create automated acceptance tests for this functionality, in order to test the ability to create posts from an end-user’s perspective. While unit tests are for testing sections of an application’s functionality from the perspective of other sections of the application, acceptance tests are testing from the user’s perspective. In other words, they test what the application needs to do to be acceptable.

First, we will test logging into the admin. Open up blogengine/tests.py and amend it as follows:

from django.test import TestCase, LiveServerTestCase, Client
from django.utils import timezone
from blogengine.models import Post
# Create your tests here.
class PostTest(TestCase):
def test_create_post(self):
# Create the post
post = Post()
# Set the attributes
post.title = 'My first post'
post.text = 'This is my first blog post'
post.pub_date = timezone.now()
# Save it
post.save()
# Check we can find it
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post, post)
# Check attributes
self.assertEquals(only_post.title, 'My first post')
self.assertEquals(only_post.text, 'This is my first blog post')
self.assertEquals(only_post.pub_date.day, post.pub_date.day)
self.assertEquals(only_post.pub_date.month, post.pub_date.month)
self.assertEquals(only_post.pub_date.year, post.pub_date.year)
self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
self.assertEquals(only_post.pub_date.second, post.pub_date.second)
class AdminTest(LiveServerTestCase):
def test_login(self):
# Create client
c = Client()
# Get login page
response = c.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)

First of all, we import two new objects from django.test, LiveServerTestCase and Client. Then we create the first part of our first test for the admin, named AdminTest. Eventually, this will test that we can log successfully into the admin interface. For now, we’re just doing the following:

  • Creating a Client object
  • Fetching the /admin/ route
  • Asserting that the status code for this HTTP request is 200, (in other words, that the page was fetched successfully).

If you run python manage.py test, you should see that both tests pass successfully. Now we’ll extend AdminTest - we’ll verify that the response contains the string ‘Log in’, which in the Django admin interface, appears on the login page:

class AdminTest(LiveServerTestCase):
def test_login(self):
# Create client
c = Client()
# Get login page
response = c.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)

Here response.content is a string containing the content of the HTTP response - we’re asserting that the substring ‘Log in’ appears in there. If you run python manage.py test again, it should pass.

Now, we need to actually log in. This could be fiddly, but Django has a handy convenience method to log you in when testing:

class AdminTest(LiveServerTestCase):
def test_login(self):
# Create client
c = Client()
# Get login page
response = c.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
# Log the user in
c.login(username='bobsmith', password="password")
# Check response code
response = c.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)

Here, we use the login method of the Client object to log into the admin interface, and then we fetch the /admin/ route again. We assert that we get a 200 status code, and we assert that the response contains the string ‘Log out’ - in other words, that we are logged in.

Try running python manage.py test and we’ll get an error., because the user details we’ve used to log in don’t exist. Let’s resolve that.

Now, you could put your own credentials in there, but that’s not a good idea because it’s a security risk. Instead, we’ll create a fixture for the test user that will be loaded when the tests are run. Run the following command:

$ python manage.py createsuperuser

Give the username as bobsmith, the email address as `bob@example.com, and the password aspassword`. Once that’s done, run these commands to dump the existing users to a fixture:

$ mkdir blogengine/fixtures
$ python manage.py dumpdata auth.User --indent=2 > blogengine/fixtures/users.json

This will dump all of the existing users to blogengine/fixtures/users.json. You may wish to edit this file to remove your own superuser account and leave only the newly created one in there.

Next we need to amend our test to load this fixture:

class AdminTest(LiveServerTestCase):
fixtures = ['users.json']
def test_login(self):
# Create client
c = Client()
# Get login page
response = c.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
# Log the user in
c.login(username='bobsmith', password="password")
# Check response code
response = c.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)

Now, if you run python manage.py test, you should find that the test passes. Next, we’ll test that we can log out:

class AdminTest(LiveServerTestCase):
fixtures = ['users.json']
def test_login(self):
# Create client
c = Client()
# Get login page
response = c.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
# Log the user in
c.login(username='bobsmith', password="password")
# Check response code
response = c.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
def test_logout(self):
# Create client
c = Client()
# Log in
c.login(username='bobsmith', password="password")
# Check response code
response = c.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
# Log out
c.logout()
# Check response code
response = c.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)

This test works along very similar lines. We log in, verify that ‘Log out’ is in the response, then we log out, and verify that ‘Log in’ is in the response. Run the tests again, and they should pass. Assuming they do, let’s commit our changes again:

$ git add blogengine/
$ git commit -m 'Added tests for admin auth'

This code is a little repetitive. We create the client twice, when we could do so only once. Amend the AdminTest class as follows:

class AdminTest(LiveServerTestCase):
fixtures = ['users.json']
def setUp(self):
self.client = Client()
def test_login(self):
# Get login page
response = self.client.get('/admin/')
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
# Log the user in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
def test_logout(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
# Log out
self.client.logout()
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)

The setUp() method is automatically run when the test runs, and ensures we only need to start up the client once. Run your tests to make sure they pass, then commit your changes:

$ git add blogengine/
$ git commit -m 'Refactored admin test'

Now, we’ll implement a test for creating a new post. The admin interface implements URLs for creating new instances of a model in a consistent format of /admin/app_name/model_name/add/, so the URL for adding a new post will be /admin/blogengine/post/add/.

Add this method to the AdminTest class:

def test_create_post(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)

Try running it and this will fail, because we haven’t registered the Post model in the Django admin. So we need to do that. To do so, open a new file at blogengine/admin.py and add the following code:

import models
from django.contrib import admin
admin.site.register(models.Post)

Now, run python manage.py test and the test should pass. If you want to confirm that the post model appears in the admin, run python manage.py runserver and click here.

So now we can reach the page for adding a post, but we haven’t yet tested that we can submit one. Let’s remedy that:

def test_create_post(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)
# Create the new post
response = self.client.post('/admin/blogengine/post/add/', {
'title': 'My first post',
'text': 'This is my first post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new post now in database
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)

Here we submit the new post via HTTP POST, with all the data passed through. This mirrors the form created by the Django admin interface - if you take a look at the HTML generated by the admin, you’ll see that the inputs are given names that match these. Note that the pub_date field, because it represents a datetime object, is split up into a separate date and time field. Also note the parameter follow=True - this denotes that the test client should follow any HTTP redirect.

We confirm that the POST request responded with a 200 code, denoting success. We also confirm that the response included the phrase ‘added successfully’. Finally we confirm that there is now a single Post object in the database. Don’t worry about any existing content - Django creates a dedicated test database and destroys it after the tests are done, so you can be sure that no posts are present unless you explicitly load them from a fixture.

We can now test creating a post, but we also need to ensure we can test editing and deleting them. First we’ll add a test for editing posts:

def test_edit_post(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.pub_date = timezone.now()
post.save()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the post
response = self.client.post('/admin/blogengine/post/1/', {
'title': 'My second post',
'text': 'This is my second blog post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post.title, 'My second post')
self.assertEquals(only_post.text, 'This is my second blog post')

Here we create a new blog post, then verify we can edit it by resubmitting it with different values, and checking that we get the expected response, and that the data in the database has been updated. Run python manage.py test, and this should pass.

Finally, we’ll set up a test for deleting posts:

def test_delete_post(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.pub_date = timezone.now()
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the post
response = self.client.post('/admin/blogengine/post/1/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 0)

Again, this is pretty similar to what we did before. We create a new post, verify that it is the sole post in the database, and log into the admin. Then we delete the post via the admin, and confirm that the admin interface confirmed it has been deleted, and the post is gone from the database.

I think it’s now time to commit again:

$ git add blogengine/
$ git commit -m 'Post admin tests in place'

So we now know that we can create, edit and delete posts, and we have tests in place to confirm this. So our next task is to be able to display our posts.

For now, to keep things simple, we’re only going to implement the index view - in other words, all the posts in reverse chronological order. We’ll use Django’s generic views to keep things really easy.

Django’s generic views are another really handy feature. As mentioned earlier, a view is a function or class that describes how a specific route should render an object. Now, there are many tasks that recur in web development. For instance, many web pages you may have seen may be a list of objects - in this case, the index page for a blog is a list of blog posts. For that reason, Django has the ListView generic view, which makes it easy to render a list of objects.

Now, like before, we want to have a test in place. Open up blogengine/tests.py and add the following class at the end of the file:

class PostViewTest(LiveServerTestCase):
def setUp(self):
self.client = Client()
def test_index(self):
# Create the post
post = Post()
post.title = 'My first post'
post.text = 'This is my first blog post'
post.pub_date = timezone.now()
post.save()
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Fetch the index
response = self.client.get('/')
self.assertEquals(response.status_code, 200)
# Check the post title is in the response
self.assertTrue(post.title in response.content)
# Check the post text is in the response
self.assertTrue(post.text in response.content)
# Check the post date is in the response
self.assertTrue(str(post.pub_date.year) in response.content)
self.assertTrue(post.pub_date.strftime('%b') in response.content)
self.assertTrue(str(post.pub_date.day) in response.content)

Here we create the post, and assert that it is the sole post object. We then fetch the index page, and assert that the HTTP status code is 200 (ie. the page exists and is returned). We then verify that the response contains the post title, text and publication date.

Note that for the month, we need to do a bit of jiggery-pokery to get the month name. By default Django will return short month names (eg Jan, Feb etc), but Python stores months as numbers, so we need to format it as a short month name using %b.

If you run this, you will get an error because the index route isn’t implemented. So let’s fix that. Open up the existing django_tutorial_blog_ng/urls.py file and amend it to look like this:

from django.conf.urls import patterns, include, url
from django.contrib import admin
admin.autodiscover()
urlpatterns = patterns('',
# Examples:
# url(r'^$', 'django_tutorial_blog_ng.views.home', name='home'),
# url(r'^blog/', include('blog.urls')),
url(r'^admin/', include(admin.site.urls)),
# Blog URLs
url(r'^.*$', include('blogengine.urls')),
)

Then, create a new file at blogengine/urls.py and edit it as follows:

from django.conf.urls import patterns, url
from django.views.generic import ListView
from blogengine.models import Post
urlpatterns = patterns('',
# Index
url('^$', ListView.as_view(
model=Post,
)),
)

A little explanation is called for. The project has its own urls.py file that handles routing throughout the project. However, because Django encourages you to make your apps reusable, we want to keep the routes in the individual apps as far as possible. So, in the project file, we include the blogengine/urls.py file.

In the app-specific urls.py, we import the Post model and the ListView generic view. We then define a route for the index page - the regular expression ^$ will match only an empty string, so that page will be the index. For this route, we then call the as_view() method of the ListView object, and set the model as Post.

Now, if you either run the tests, or run the development server and visit the index page, you’ll see that it isn’t working yet - you should see the error TemplateDoesNotExist: blogengine/post_list.html. This tells us that we need to create a template called blogengine/post_list.html, so let’s do that. First of all, add the following at the end of django_tutorial_blog_ng/settings.py:

# Template directory
TEMPLATE_DIRS = [os.path.join(BASE_DIR, 'templates')]

Next, create the folders for the templates, and a blank post_list.html file:

$ mkdir templates
$ mkdir templates/blogengine
$ touch templates/blogengine/post_list.html

Now, run your tests again, and you’ll see that the template now exists, but a new error is showing up:

Creating test database for alias 'default'...
......F
======================================================================
FAIL: test_index (blogengine.tests.PostViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 189, in test_index
self.assertTrue(post.title in response.content)
AssertionError: False is not true
----------------------------------------------------------------------
Ran 7 tests in 2.162s
FAILED (failures=1)
Destroying test database for alias 'default'...

To fix this, we make sure the template shows the data we want. Open up templates/blogengine/post_list.html and enter the following:

<html>
<head>
<title>My Django Blog</title>
</head>
<body>
{% for post in object_list %}
<h1>{{ post.title }}</h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text }}
{% endfor %}
</body>
</html>

This is only a very basic template, and we’ll expand upon it in future.

With that done, you can run python manage.py test, and it should pass. Well done! Don’t forget to commit your changes:

$ git add django_tutorial_blog_ng/ templates/ blogengine/
$ git commit -m 'Implemented list view for posts'

And that’s all for this lesson! We’ve done a hell of a lot in this lesson - set up our project, created a comprehensive test suite for it, and implemented the basic functionality. Next time we’ll make it a bit prettier using Twitter Bootstrap, as well as implementing more of the basic functionality for the blog.

You can find the source code on Github. For your convenience, I’ve tagged this lesson as lesson-1, so you can just clone the repository and switch to the end of this lesson with the following commands:

$ git clone https://github.com/matthewbdaly/django_tutorial_blog_ng.git
$ cd django_tutorial_blog_ng
$ git checkout lesson-1

That way, you can easily check that what you’ve done matches up with the repository. Future lessons will be similarly tagged to make them easy to navigate.

Recent Posts

Higher-order Components in React

Creating Your Own Dependency Injection Container in PHP

Understanding Query Objects

Writing a Custom Sniff for PHP Codesniffer

You Don't Need That Module Package

About me

I'm a web and mobile app developer based in Norfolk. My skillset includes Python, PHP and Javascript, and I have extensive experience working with CodeIgniter, Laravel, Zend Framework, Django, Phonegap and React.js.