Django Blog Tutorial - the Next Generation - Part 7

Published by at 25th August 2014 4:15 pm

Hello once again! In this instalment we'll cover:

  • Caching your content with Memcached to improve your site's performance
  • Refactoring and simplifying our tests
  • Implementing additional feeds
  • Creating a simple search engine

Don't forget to activate your virtualenv:

$ source venv/bin/activate

Now let's get started!

Memcached

If you frequent (or used to frequent) social media sites like Reddit, Slashdot or Digg, you may be familiar with something called variously the Digg or Slashdot effect, whereby if a page gets submitted to a social media site, and subsequently becomes popular, it can be hit by a huge number of HTTP requests in a very short period of time, slowing it down or even taking the server down completely.

Now, as a general rule of thumb, for most dynamic websites such as blogs, the bottleneck is not the web server or the programming language, but the database. If you have a lot of people hitting the same page over and over again in quick succession, then you're essentially running the same query over and over again and getting the same result each time, which is expensive in terms of processing power. What you need to be able to do is cache the results of the query in memory for a given period of time so that the number of queries is reduced.

That's where Memcached comes in. It's a simple key-value store that allows you to store values in memory for a given period of time so that they can be retrieved without having to query the database. Memcached is a very common choice for caching, and is by far the fastest and most efficient type of cache available for Django. It's also available on Heroku's free tier.

Django has a very powerful caching framework that supports numerous types of cache in addition to Memcached, such as:

  • Database caching
  • Filesystem caching
  • Local memory caching

There are also third-party backends for using other caching systems such as Redis.

Now, the cache can be used in a number of different ways. You can cache only certain parts of your site if you wish. However, because our site is heavily content-driven, we should be pretty safe to use the per-site cache, which is the simplest way to set up caching.

In order to set up Memcached, there's a couple of Python libraries we'll need. If you want to install them locally, however, you'll need to install both memcached and libmemcached (on Ubuntu, the packages you need are called memcached and libmemcached-dev) on your development machine. If you don't want to do this, then just copy and paste these lines into requirements.txt instead:

1django-pylibmc-sasl==0.2.4
2pylibmc==1.3.0

If you are happy to install these dependencies locally, then run this command once memcached and libmemcached are installed:

$ pip install pylibmc django-pylibmc-sasl

With that done let's configure Memcached. Open up the settings file and add the following at the bottom:

1def get_cache():
2 import os
3 try:
4 os.environ['MEMCACHE_SERVERS'] = os.environ['MEMCACHIER_SERVERS'].replace(',', ';')
5 os.environ['MEMCACHE_USERNAME'] = os.environ['MEMCACHIER_USERNAME']
6 os.environ['MEMCACHE_PASSWORD'] = os.environ['MEMCACHIER_PASSWORD']
7 return {
8 'default': {
9 'BACKEND': 'django_pylibmc.memcached.PyLibMCCache',
10 'TIMEOUT': 300,
11 'BINARY': True,
12 'OPTIONS': { 'tcp_nodelay': True }
13 }
14 }
15 except:
16 return {
17 'default': {
18 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'
19 }
20 }
21
22CACHES = get_cache()
23CACHE_MIDDLEWARE_ALIAS = 'default'
24CACHE_MIDDLEWARE_SECONDS = 300
25CACHE_MIDDLEWARE_KEY_PREFIX = ''

Then add the following to MIDDLEWARE_CLASSES:

1 'django.middleware.cache.UpdateCacheMiddleware',
2 'django.middleware.common.CommonMiddleware',
3 'django.middleware.cache.FetchFromCacheMiddleware',

That's it! The first section configures the application to use Memcached to cache the content when running on Heroku, and sets some configuration parameters, while the second section tells Django to use the per-site cache in order to cache all the site content.

Now, Heroku doesn't include Memcached by default - instead it's available as an add-on called Memcachier. To use add-ons you need to set up a credit card for billing. We will set it up to use the free developer plan, but if you outgrow this you can easily switch to a paid plan. To add Memcachier, run this command:

$ heroku addons:add memcachier:dev

Please note that Memcachier can take a few minutes to get set up, so you may want to leave it a little while between adding it and pushing up your changes. Now we'll commit our changes:

1$ git add requirements.txt django_tutorial_blog_ng/settings.py
2$ git commit -m 'Implemented caching with Memcached'

Then we'll push them up to our remote repository and to Heroku:

1$ git push origin master
2$ git push heroku master

And that's all you need to do to set up Memcached. In addition to storing your query results in Memcached, enabling the caching framework in Django will also set various HTTP headers to enable web proxies and browsers to cache content for an appropriate length of time. If you open up your browser's developer tools and compare the response headers for your homepage on the latest version of the code with the previous version, you'll notice that a number of additional headers appear, including Cache-Control, Expires and Last-Modified. These tell web browsers and web proxies how often to request the latest version of the HTML document, in order to help you reduce the bandwidth used.

As you can see, for a site like this where you are the only person adding content, it's really easy to implement caching with Django, and for a blog there's very little reason not to do it. If you're not using Heroku and are instead hosting your site on a VPS, then the configuration will be somewhat different - see here for details. You can also find information on using other cache backends on the same page.

That isn't all you can do to speed up your site. Heroku doesn't seem to be very good for serving static files, and if your site is attracting a lot of traffic you might want to host your static files elsewhere, such as on Amazon's S3 service. Doing so is outside the scope of this tutorial, but for that use case, you should check out django-storages.

Clearing the cache automatically

There is one issue with this implementation. As it is right now, if you view the home page, add a post, then reload the page, you may not see the new post immediately because the cache will continue serving the old version until it has expired. That behaviour is less than ideal - we would like the cache to be cleared automatically when a new post gets added so that users will see the new version immediately. That response will still be cached afterwards, so it only means one extra query.

This is the ideal place to introduce signals. Signals are a way to carry out a given action when an event takes place. In our case, we plan to clear the cache when a post is saved (either created or updated).

Note that as we'll be testing the behaviour of the cache at this point, you'll need to install Memcached on your local machine, and we'll need to change the settings to fall back to our local Memcached instance:

1def get_cache():
2 import os
3 try:
4 os.environ['MEMCACHE_SERVERS'] = os.environ['MEMCACHIER_SERVERS'].replace(',', ';')
5 os.environ['MEMCACHE_USERNAME'] = os.environ['MEMCACHIER_USERNAME']
6 os.environ['MEMCACHE_PASSWORD'] = os.environ['MEMCACHIER_PASSWORD']
7 return {
8 'default': {
9 'BACKEND': 'django_pylibmc.memcached.PyLibMCCache',
10 'TIMEOUT': 300,
11 'BINARY': True,
12 'OPTIONS': { 'tcp_nodelay': True }
13 }
14 }
15 except:
16 return {
17 'default': {
18 'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
19 'LOCATION': '127.0.0.1:11211'
20 }
21 }
22
23CACHES = get_cache()
24CACHE_MIDDLEWARE_ALIAS = 'default'
25CACHE_MIDDLEWARE_SECONDS = 300
26CACHE_MIDDLEWARE_KEY_PREFIX = ''

If you don't want to install Memcached locally, you can skip this step, but be aware that the test we write for clearing the cache will always pass if you do skip it.

Then we'll run our tests to make sure nothing has been broken:

1$ python manage.py jenkins blogengine
2Creating test database for alias 'default'...
3.......................
4----------------------------------------------------------------------
5Ran 23 tests in 6.164s
6
7OK

Let's commit:

1$ git add django_tutorial_blog_ng/settings.py
2$ git commit -m 'Now use Memcached in development'

Now we'll add a test for clearing the cache to PostViewTest:

1 def test_clear_cache(self):
2 # Create the category
3 category = Category()
4 category.name = 'python'
5 category.description = 'The Python programming language'
6 category.save()
7
8 # Create the tag
9 tag = Tag()
10 tag.name = 'perl'
11 tag.description = 'The Perl programming language'
12 tag.save()
13
14 # Create the author
15 author = User.objects.create_user('testuser', 'user@example.com', 'password')
16 author.save()
17
18 # Create the site
19 site = Site()
20 site.name = 'example.com'
21 site.domain = 'example.com'
22 site.save()
23
24 # Create the first post
25 post = Post()
26 post.title = 'My first post'
27 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
28 post.slug = 'my-first-post'
29 post.pub_date = timezone.now()
30 post.author = author
31 post.site = site
32 post.category = category
33 post.save()
34 post.tags.add(tag)
35
36 # Check new post saved
37 all_posts = Post.objects.all()
38 self.assertEquals(len(all_posts), 1)
39
40 # Fetch the index
41 response = self.client.get('/')
42 self.assertEquals(response.status_code, 200)
43
44 # Create the second post
45 post = Post()
46 post.title = 'My second post'
47 post.text = 'This is [my second blog post](http://127.0.0.1:8000/)'
48 post.slug = 'my-second-post'
49 post.pub_date = timezone.now()
50 post.author = author
51 post.site = site
52 post.category = category
53 post.save()
54 post.tags.add(tag)
55
56 # Fetch the index again
57 response = self.client.get('/')
58
59 # Check second post present
60 self.assertTrue('my second blog post' in response.content)

This should be fairly self-explanatory. We create one post, and request the index page. We then add a second post, request the index page again, and check for the second post. The test should fail because the cached version is returned, rather than the version in the database.

Now we have a test in place, we can implement a fix. First, add this to the top of your models.py:

1from django.db.models.signals import post_save
2from django.core.cache import cache

Then add the following at the bottom of the file:

1
2# Define signals
3def new_post(sender, instance, created, **kwargs):
4 cache.clear()
5
6# Set up signals
7post_save.connect(new_post, sender=Post)

This is fairly straightforward. What we're doing is first defining a function called new_post that is called when a new post is created. We then connect it to the post_save signal. When a post is saved, it calls new_post, which clears the cache, making sure users are seeing the latest and greatest version of your site immediately.

Let's test it:

1$ python manage.py jenkins blogengine
2Creating test database for alias 'default'...
3........................
4----------------------------------------------------------------------
5Ran 24 tests in 8.473s
6
7OK
8Destroying test database for alias 'default'...

There are a number of signals available, and when you create one, you have access to the created object via the instance parameter. Using signals you can implement all kinds of functionality. For instance, you could implement the functionality to send an email when a new post is published.

If you're using Travis CI, you'll also need to update the config file:

1language: python
2python:
3- "2.7"
4services: memcached
5before_install:
6 - sudo apt-get install -y libmemcached-dev
7# command to install dependencies
8install: "pip install -r requirements.txt"
9# command to run tests
10script: coverage run --include="blogengine/*" --omit="blogengine/migrations/*" manage.py test blogengine
11after_success:
12 coveralls

Time to commit:

1$ git add blogengine/ .travis.yml
2$ git commit -m 'Now clear cache when post added'

Formatting for RSS feeds

Now, we want to offer more than one option for RSS feeds. For instance, if your blog is aggregated on a site such as Planet Python, but you also blog about JavaScript, you may want to be able to provide a feed for posts in the python category only.

If you have written any posts that use any of Markdown's custom formatting, you may notice that if you load your RSS feed in a reader, it isn't formatted as Markdown. Let's fix that. First we'll amend our test:

1class FeedTest(BaseAcceptanceTest):
2 def test_all_post_feed(self):
3 # Create the category
4 category = Category()
5 category.name = 'python'
6 category.description = 'The Python programming language'
7 category.save()
8
9 # Create the tag
10 tag = Tag()
11 tag.name = 'python'
12 tag.description = 'The Python programming language'
13 tag.save()
14
15 # Create the author
16 author = User.objects.create_user('testuser', 'user@example.com', 'password')
17 author.save()
18
19 # Create the site
20 site = Site()
21 site.name = 'example.com'
22 site.domain = 'example.com'
23 site.save()
24
25 # Create a post
26 post = Post()
27 post.title = 'My first post'
28 post.text = 'This is my *first* blog post'
29 post.slug = 'my-first-post'
30 post.pub_date = timezone.now()
31 post.author = author
32 post.site = site
33 post.category = category
34
35 # Save it
36 post.save()
37
38 # Add the tag
39 post.tags.add(tag)
40 post.save()
41
42 # Check we can find it
43 all_posts = Post.objects.all()
44 self.assertEquals(len(all_posts), 1)
45 only_post = all_posts[0]
46 self.assertEquals(only_post, post)
47
48 # Fetch the feed
49 response = self.client.get('/feeds/posts/')
50 self.assertEquals(response.status_code, 200)
51
52 # Parse the feed
53 feed = feedparser.parse(response.content)
54
55 # Check length
56 self.assertEquals(len(feed.entries), 1)
57
58 # Check post retrieved is the correct one
59 feed_post = feed.entries[0]
60 self.assertEquals(feed_post.title, post.title)
61 self.assertTrue('This is my <em>first</em> blog post' in feed_post.description)

Don't forget to run the tests to make sure they fail. Now, let's fix it:

1from django.shortcuts import render
2from django.views.generic import ListView
3from blogengine.models import Category, Post, Tag
4from django.contrib.syndication.views import Feed
5from django.utils.encoding import force_unicode
6from django.utils.safestring import mark_safe
7import markdown2
8
9# Create your views here.
10class CategoryListView(ListView):
11 def get_queryset(self):
12 slug = self.kwargs['slug']
13 try:
14 category = Category.objects.get(slug=slug)
15 return Post.objects.filter(category=category)
16 except Category.DoesNotExist:
17 return Post.objects.none()
18
19
20class TagListView(ListView):
21 def get_queryset(self):
22 slug = self.kwargs['slug']
23 try:
24 tag = Tag.objects.get(slug=slug)
25 return tag.post_set.all()
26 except Tag.DoesNotExist:
27 return Post.objects.none()
28
29
30class PostsFeed(Feed):
31 title = "RSS feed - posts"
32 link = "feeds/posts/"
33 description = "RSS feed - blog posts"
34
35 def items(self):
36 return Post.objects.order_by('-pub_date')
37
38 def item_title(self, item):
39 return item.title
40
41 def item_description(self, item):
42 extras = ["fenced-code-blocks"]
43 content = mark_safe(markdown2.markdown(force_unicode(item.text),
44 extras = extras))
45 return content

All we're doing here is amending the item_description method of PostsFeed to render it as Markdown. Now let's run our tests again:

1$ python manage.py jenkins
2Creating test database for alias 'default'...
3........................
4----------------------------------------------------------------------
5Ran 24 tests in 9.370s
6
7OK
8Destroying test database for alias 'default'...

With that done, we'll commit our changes:

1$ git add blogengine/
2$ git commit -m 'Fixed rendering for post feed'

Refactoring our tests

Now, before we get into implementing the feed, our tests are a bit verbose. We create a lot of items over and over again - let's sort that out. Factory Boy is a handy Python module that allows you to create easy-to-use factories for creating objects over and over again in tests. Let's install it:

1$ pip install factory_boy
2$ pip freeze > requirements.txt
3$ git add requirements.txt
4$ git commit -m 'Installed Factory Boy'

Now let's set up a factory for creating posts. Add this at the top of the test file:

import factory.django

Then, before your actual tests, insert the following:

1# Factories
2class SiteFactory(factory.django.DjangoModelFactory):
3 class Meta:
4 model = Site
5 django_get_or_create = (
6 'name',
7 'domain'
8 )
9
10 name = 'example.com'
11 domain = 'example.com'

Now, wherever you call Site(), add its attributes, and save it, replace those lines with the following:

site = SiteFactory()

Much simpler and more concise, I'm sure you'll agree! Now, let's run the tests to make sure they aren't broken:

1$ python manage.py test blogengine
2Creating test database for alias 'default'...
3........................
4----------------------------------------------------------------------
5Ran 24 tests in 7.482s
6
7OK
8Destroying test database for alias 'default'...

Let's commit again:

1$ git add blogengine/tests.py
2$ git commit -m 'Now use Factory Boy for site objects'

Let's do the same thing with Category objects:

1class CategoryFactory(factory.django.DjangoModelFactory):
2 class Meta:
3 model = Category
4 django_get_or_create = (
5 'name',
6 'description',
7 'slug'
8 )
9
10 name = 'python'
11 description = 'The Python programming language'
12 slug = 'python'

Again, just find every time we call Category() and replace it with the following:

category = CategoryFactory()

Now if we run our tests, we'll notice a serious error:

1$ python manage.py test blogengine
2Creating test database for alias 'default'...
3EE..EE.EE.EE...E.EEE..E.
4======================================================================
5ERROR: test_create_category (blogengine.tests.PostTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 42, in test_create_category
9 category = CategoryFactory()
10 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
11 return cls.create(**kwargs)
12 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
13 return cls._generate(True, attrs)
14 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
15 obj = cls._prepare(create, **attrs)
16 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
17 return cls._create(model_class, *args, **kwargs)
18 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
19 return cls._get_or_create(model_class, *args, **kwargs)
20 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
21 obj, _created = manager.get_or_create(*args, **key_fields)
22 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
23 return self.get_queryset().get_or_create(**kwargs)
24 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
25 obj.save(force_insert=True, using=self.db)
26TypeError: save() got an unexpected keyword argument 'force_insert'
27
28======================================================================
29ERROR: test_create_post (blogengine.tests.PostTest)
30----------------------------------------------------------------------
31Traceback (most recent call last):
32 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 80, in test_create_post
33 category = CategoryFactory()
34 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
35 return cls.create(**kwargs)
36 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
37 return cls._generate(True, attrs)
38 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
39 obj = cls._prepare(create, **attrs)
40 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
41 return cls._create(model_class, *args, **kwargs)
42 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
43 return cls._get_or_create(model_class, *args, **kwargs)
44 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
45 obj, _created = manager.get_or_create(*args, **key_fields)
46 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
47 return self.get_queryset().get_or_create(**kwargs)
48 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
49 obj.save(force_insert=True, using=self.db)
50TypeError: save() got an unexpected keyword argument 'force_insert'
51
52======================================================================
53ERROR: test_create_post (blogengine.tests.AdminTest)
54----------------------------------------------------------------------
55Traceback (most recent call last):
56 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 339, in test_create_post
57 category = CategoryFactory()
58 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
59 return cls.create(**kwargs)
60 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
61 return cls._generate(True, attrs)
62 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
63 obj = cls._prepare(create, **attrs)
64 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
65 return cls._create(model_class, *args, **kwargs)
66 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
67 return cls._get_or_create(model_class, *args, **kwargs)
68 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
69 obj, _created = manager.get_or_create(*args, **key_fields)
70 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
71 return self.get_queryset().get_or_create(**kwargs)
72 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
73 obj.save(force_insert=True, using=self.db)
74TypeError: save() got an unexpected keyword argument 'force_insert'
75
76======================================================================
77ERROR: test_create_post_without_tag (blogengine.tests.AdminTest)
78----------------------------------------------------------------------
79Traceback (most recent call last):
80 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 378, in test_create_post_without_tag
81 category = CategoryFactory()
82 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
83 return cls.create(**kwargs)
84 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
85 return cls._generate(True, attrs)
86 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
87 obj = cls._prepare(create, **attrs)
88 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
89 return cls._create(model_class, *args, **kwargs)
90 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
91 return cls._get_or_create(model_class, *args, **kwargs)
92 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
93 obj, _created = manager.get_or_create(*args, **key_fields)
94 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
95 return self.get_queryset().get_or_create(**kwargs)
96 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
97 obj.save(force_insert=True, using=self.db)
98TypeError: save() got an unexpected keyword argument 'force_insert'
99
100======================================================================
101ERROR: test_delete_category (blogengine.tests.AdminTest)
102----------------------------------------------------------------------
103Traceback (most recent call last):
104 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 245, in test_delete_category
105 category = CategoryFactory()
106 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
107 return cls.create(**kwargs)
108 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
109 return cls._generate(True, attrs)
110 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
111 obj = cls._prepare(create, **attrs)
112 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
113 return cls._create(model_class, *args, **kwargs)
114 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
115 return cls._get_or_create(model_class, *args, **kwargs)
116 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
117 obj, _created = manager.get_or_create(*args, **key_fields)
118 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
119 return self.get_queryset().get_or_create(**kwargs)
120 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
121 obj.save(force_insert=True, using=self.db)
122TypeError: save() got an unexpected keyword argument 'force_insert'
123
124======================================================================
125ERROR: test_delete_post (blogengine.tests.AdminTest)
126----------------------------------------------------------------------
127Traceback (most recent call last):
128 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 467, in test_delete_post
129 category = CategoryFactory()
130 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
131 return cls.create(**kwargs)
132 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
133 return cls._generate(True, attrs)
134 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
135 obj = cls._prepare(create, **attrs)
136 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
137 return cls._create(model_class, *args, **kwargs)
138 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
139 return cls._get_or_create(model_class, *args, **kwargs)
140 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
141 obj, _created = manager.get_or_create(*args, **key_fields)
142 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
143 return self.get_queryset().get_or_create(**kwargs)
144 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
145 obj.save(force_insert=True, using=self.db)
146TypeError: save() got an unexpected keyword argument 'force_insert'
147
148======================================================================
149ERROR: test_edit_category (blogengine.tests.AdminTest)
150----------------------------------------------------------------------
151Traceback (most recent call last):
152 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 221, in test_edit_category
153 category = CategoryFactory()
154 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
155 return cls.create(**kwargs)
156 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
157 return cls._generate(True, attrs)
158 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
159 obj = cls._prepare(create, **attrs)
160 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
161 return cls._create(model_class, *args, **kwargs)
162 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
163 return cls._get_or_create(model_class, *args, **kwargs)
164 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
165 obj, _created = manager.get_or_create(*args, **key_fields)
166 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
167 return self.get_queryset().get_or_create(**kwargs)
168 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
169 obj.save(force_insert=True, using=self.db)
170TypeError: save() got an unexpected keyword argument 'force_insert'
171
172======================================================================
173ERROR: test_edit_post (blogengine.tests.AdminTest)
174----------------------------------------------------------------------
175Traceback (most recent call last):
176 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 410, in test_edit_post
177 category = CategoryFactory()
178 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
179 return cls.create(**kwargs)
180 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
181 return cls._generate(True, attrs)
182 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
183 obj = cls._prepare(create, **attrs)
184 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
185 return cls._create(model_class, *args, **kwargs)
186 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
187 return cls._get_or_create(model_class, *args, **kwargs)
188 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
189 obj, _created = manager.get_or_create(*args, **key_fields)
190 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
191 return self.get_queryset().get_or_create(**kwargs)
192 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
193 obj.save(force_insert=True, using=self.db)
194TypeError: save() got an unexpected keyword argument 'force_insert'
195
196======================================================================
197ERROR: test_all_post_feed (blogengine.tests.FeedTest)
198----------------------------------------------------------------------
199Traceback (most recent call last):
200 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 810, in test_all_post_feed
201 category = CategoryFactory()
202 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
203 return cls.create(**kwargs)
204 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
205 return cls._generate(True, attrs)
206 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
207 obj = cls._prepare(create, **attrs)
208 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
209 return cls._create(model_class, *args, **kwargs)
210 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
211 return cls._get_or_create(model_class, *args, **kwargs)
212 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
213 obj, _created = manager.get_or_create(*args, **key_fields)
214 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
215 return self.get_queryset().get_or_create(**kwargs)
216 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
217 obj.save(force_insert=True, using=self.db)
218TypeError: save() got an unexpected keyword argument 'force_insert'
219
220======================================================================
221ERROR: test_category_page (blogengine.tests.PostViewTest)
222----------------------------------------------------------------------
223Traceback (most recent call last):
224 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 640, in test_category_page
225 category = CategoryFactory()
226 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
227 return cls.create(**kwargs)
228 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
229 return cls._generate(True, attrs)
230 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
231 obj = cls._prepare(create, **attrs)
232 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
233 return cls._create(model_class, *args, **kwargs)
234 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
235 return cls._get_or_create(model_class, *args, **kwargs)
236 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
237 obj, _created = manager.get_or_create(*args, **key_fields)
238 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
239 return self.get_queryset().get_or_create(**kwargs)
240 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
241 obj.save(force_insert=True, using=self.db)
242TypeError: save() got an unexpected keyword argument 'force_insert'
243
244======================================================================
245ERROR: test_clear_cache (blogengine.tests.PostViewTest)
246----------------------------------------------------------------------
247Traceback (most recent call last):
248 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 753, in test_clear_cache
249 category = CategoryFactory()
250 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
251 return cls.create(**kwargs)
252 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
253 return cls._generate(True, attrs)
254 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
255 obj = cls._prepare(create, **attrs)
256 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
257 return cls._create(model_class, *args, **kwargs)
258 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
259 return cls._get_or_create(model_class, *args, **kwargs)
260 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
261 obj, _created = manager.get_or_create(*args, **key_fields)
262 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
263 return self.get_queryset().get_or_create(**kwargs)
264 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
265 obj.save(force_insert=True, using=self.db)
266TypeError: save() got an unexpected keyword argument 'force_insert'
267
268======================================================================
269ERROR: test_index (blogengine.tests.PostViewTest)
270----------------------------------------------------------------------
271Traceback (most recent call last):
272 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 518, in test_index
273 category = CategoryFactory()
274 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
275 return cls.create(**kwargs)
276 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
277 return cls._generate(True, attrs)
278 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
279 obj = cls._prepare(create, **attrs)
280 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
281 return cls._create(model_class, *args, **kwargs)
282 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
283 return cls._get_or_create(model_class, *args, **kwargs)
284 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
285
286
287 obj, _created = manager.get_or_create(*args, **key_fields)
288 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
289 return self.get_queryset().get_or_create(**kwargs)
290 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
291 obj.save(force_insert=True, using=self.db)
292TypeError: save() got an unexpected keyword argument 'force_insert'
293
294======================================================================
295ERROR: test_post_page (blogengine.tests.PostViewTest)
296----------------------------------------------------------------------
297Traceback (most recent call last):
298 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 576, in test_post_page
299 category = CategoryFactory()
300 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 82, in __call__
301 return cls.create(**kwargs)
302 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 585, in create
303 return cls._generate(True, attrs)
304 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 510, in _generate
305 obj = cls._prepare(create, **attrs)
306 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/base.py", line 485, in _prepare
307 return cls._create(model_class, *args, **kwargs)
308 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 151, in _create
309
310
311 return cls._get_or_create(model_class, *args, **kwargs)
312 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/factory/django.py", line 142, in _get_or_create
313 obj, _created = manager.get_or_create(*args, **key_fields)
314 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/manager.py", line 154, in get_or_create
315 return self.get_queryset().get_or_create(**kwargs)
316 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/db/models/query.py", line 383, in get_or_create
317 obj.save(force_insert=True, using=self.db)
318TypeError: save() got an unexpected keyword argument 'force_insert'
319
320----------------------------------------------------------------------
321Ran 24 tests in 5.162s
322
323FAILED (errors=13)
324Destroying test database for alias 'default'...

Thankfully, this is easy to fix. We just need to amend the custom save() method of the Category model:

1 def save(self, *args, **kwargs):
2 if not self.slug:
3 self.slug = slugify(unicode(self.name))
4 super(Category, self).save(*args, **kwargs)

That should resolve the issue:

1$ python manage.py jenkins
2Creating test database for alias 'default'...
3........................
4----------------------------------------------------------------------
5Ran 24 tests in 7.749s
6
7OK
8Destroying test database for alias 'default'...

Let's commit again:

1$ git add blogengine/
2$ git commit -m 'Category now uses Factory Boy'

Now let's do the same thing for tags:

1class TagFactory(factory.django.DjangoModelFactory):
2 class Meta:
3 model = Tag
4 django_get_or_create = (
5 'name',
6 'description',
7 'slug'
8 )
9
10 name = 'python'
11 description = 'The Python programming language'
12 slug = 'python'

And replace the sections where we create new Tag objects:

tag = TagFactory()

Note that some tags have different values. We can easily pass different values to our TagFactory() to override the default values:

tag = TagFactory(name='perl', description='The Perl programming language')

The Tag model has the same issue as the Category one did, so let's fix that:

1 def save(self, *args, **kwargs):
2 if not self.slug:
3 self.slug = slugify(unicode(self.name))
4 super(Tag, self).save(*args, **kwargs)

We run our tests again:

1$ python manage.py test blogengine/
2Creating test database for alias 'default'...
3........................
4----------------------------------------------------------------------
5Ran 24 tests in 7.153s
6
7OK
8Destroying test database for alias 'default'...

Time to commit again:

1$ git add blogengine/
2$ git commit -m 'Now use Factory Boy for tags'

Next we'll create a factory for adding users. Note that the factory name doesn't have to match the object name, so you can create factories for different types of users. Here we create a factory for authors - you could, for instance, create a separate factory for subscribers if you wanted:

1class AuthorFactory(factory.django.DjangoModelFactory):
2 class Meta:
3 model = User
4 django_get_or_create = ('username','email', 'password',)
5
6 username = 'testuser'
7 email = 'user@example.com'
8 password = 'password'

And as before, replace those sections where we create users with the following:

author = AuthorFactory()

Run the tests again:

1$ python manage.py test blogengine
2Creating test database for alias 'default'...
3........................
4----------------------------------------------------------------------
5Ran 24 tests in 5.808s
6
7OK
8Destroying test database for alias 'default'...

We commit our changes:

1$ git add blogengine/
2$ git commit -m 'Now use Factory Boy for creating authors'

Now we'll create a flat page factory:

1
2
3class FlatPageFactory(factory.django.DjangoModelFactory):
4 class Meta:
5 model = FlatPage
6 django_get_or_create = (
7 'url',
8 'title',
9 'content'
10 )
11
12 url = '/about/'
13 title = 'About me'
14 content = 'All about me'

And use it for our flat page test:

page = FlatPageFactory()

Check the tests pass:

1$ python manage.py test blogengine
2Creating test database for alias 'default'...
3........................
4----------------------------------------------------------------------
5Ran 24 tests in 5.796s
6
7OK
8Destroying test database for alias 'default'...

And commit again:

1$ git add blogengine/
2$ git commit -m 'Now use Factory Boy for flat page test'

Now we'll create a final factory for posts:

1class PostFactory(factory.django.DjangoModelFactory):
2 class Meta:
3 model = Post
4 django_get_or_create = (
5 'title',
6 'text',
7 'slug',
8 'pub_date'
9 )
10
11 title = 'My first post'
12 text = 'This is my first blog post'
13 slug = 'my-first-post'
14 pub_date = timezone.now()
15 author = factory.SubFactory(AuthorFactory)
16 site = factory.SubFactory(SiteFactory)
17 category = factory.SubFactory(CategoryFactory)

This factory is a little bit different. Because our Post model depends on several others, we need to be able to create those additional objects on demand. By designating them as subfactories, we can easily create the associated objects for our Post object.

That means that not only can we get rid of our Post() calls, but we can also get rid of the factory calls to create the associated objects for Post models. Again, I'll leave actually doing this as an exercise for the reader, but you can always refer to the GitHub repository if you're not too sure.

Make sure your tests still pass, then commit the changes:

1$ git add blogengine/
2$ git commit -m 'Now use Factory Boy for testing posts'

Using Factory Boy made a big difference to the size of the test file - I was able to cut it down by over 200 lines of code. As your application gets bigger, it gets harder to maintain, so do what you can to keep the size down.

Additional RSS feeds

Now, let's implement our additional RSS feeds. First, we'll write a test for the category feed. Add this to the FeedTest class:

1 def test_category_feed(self):
2 # Create a post
3 post = PostFactory(text='This is my *first* blog post')
4
5 # Create another post in a different category
6 category = CategoryFactory(name='perl', description='The Perl programming language', slug='perl')
7 post2 = PostFactory(text='This is my *second* blog post', title='My second post', slug='my-second-post', category=category)
8
9 # Fetch the feed
10 response = self.client.get('/feeds/posts/category/python/')
11 self.assertEquals(response.status_code, 200)
12
13 # Parse the feed
14 feed = feedparser.parse(response.content)
15
16 # Check length
17 self.assertEquals(len(feed.entries), 1)
18
19 # Check post retrieved is the correct one
20 feed_post = feed.entries[0]
21 self.assertEquals(feed_post.title, post.title)
22 self.assertTrue('This is my <em>first</em> blog post' in feed_post.description)
23
24 # Check other post is not in this feed
25 self.assertTrue('This is my <em>second</em> blog post' not in response.content)

Here we create two posts in different categories (note that we create a new category and override the post category for it). We then fetch /feeds/posts/category/python/ and assert that it contains only one post, with the content of the first post and not the content of the second.

Run the tests and they should fail:

1$ python manage.py test blogengine
2Creating test database for alias 'default'...
3................F........
4======================================================================
5FAIL: test_category_feed (blogengine.tests.FeedTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 716, in test_category_feed
9 self.assertEquals(response.status_code, 200)
10AssertionError: 404 != 200
11
12----------------------------------------------------------------------
13Ran 25 tests in 5.804s
14
15FAILED (failures=1)
16Destroying test database for alias 'default'...

Because we haven't yet implemented that route, we get a 404 error. So let's create a route for this:

1from django.conf.urls import patterns, url
2from django.views.generic import ListView, DetailView
3from blogengine.models import Post, Category, Tag
4from blogengine.views import CategoryListView, TagListView, PostsFeed, CategoryPostsFeed
5
6urlpatterns = patterns('',
7 # Index
8 url(r'^(?P<page>\d+)?/?$', ListView.as_view(
9 model=Post,
10 paginate_by=5,
11 )),
12
13 # Individual posts
14 url(r'^(?P<pub_date__year>\d{4})/(?P<pub_date__month>\d{1,2})/(?P<slug>[a-zA-Z0-9-]+)/?$', DetailView.as_view(
15 model=Post,
16 )),
17
18 # Categories
19 url(r'^category/(?P<slug>[a-zA-Z0-9-]+)/?$', CategoryListView.as_view(
20 paginate_by=5,
21 model=Category,
22 )),
23
24 # Tags
25 url(r'^tag/(?P<slug>[a-zA-Z0-9-]+)/?$', TagListView.as_view(
26 paginate_by=5,
27 model=Tag,
28 )),
29
30 # Post RSS feed
31 url(r'^feeds/posts/$', PostsFeed()),
32
33 # Category RSS feed
34 url(r'^feeds/posts/category/(?P<slug>[a-zA-Z0-9-]+)/?$', CategoryPostsFeed()),
35)

Note that the category RSS feed route is similar to the post RSS feed route, but accepts a slug parameter. We will use this to pass through the slug for the category in question. Also note we import the CategoryPostsFeed view. Now, we need to create that view. Fortunately, because it's written as a Python class, we can extend the existing PostsFeed class. Open up your views file and amend it to look like this:

1from django.shortcuts import get_object_or_404
2from django.views.generic import ListView
3from blogengine.models import Category, Post, Tag
4from django.contrib.syndication.views import Feed
5from django.utils.encoding import force_unicode
6from django.utils.safestring import mark_safe
7import markdown2
8
9# Create your views here.
10class CategoryListView(ListView):
11 def get_queryset(self):
12 slug = self.kwargs['slug']
13 try:
14 category = Category.objects.get(slug=slug)
15 return Post.objects.filter(category=category)
16 except Category.DoesNotExist:
17 return Post.objects.none()
18
19
20class TagListView(ListView):
21 def get_queryset(self):
22 slug = self.kwargs['slug']
23 try:
24 tag = Tag.objects.get(slug=slug)
25 return tag.post_set.all()
26 except Tag.DoesNotExist:
27 return Post.objects.none()
28
29
30class PostsFeed(Feed):
31 title = "RSS feed - posts"
32 description = "RSS feed - blog posts"
33 link = '/'
34
35 def items(self):
36 return Post.objects.order_by('-pub_date')
37
38 def item_title(self, item):
39 return item.title
40
41 def item_description(self, item):
42 extras = ["fenced-code-blocks"]
43 content = mark_safe(markdown2.markdown(force_unicode(item.text),
44 extras = extras))
45 return content
46
47
48class CategoryPostsFeed(PostsFeed):
49 def get_object(self, request, slug):
50 return get_object_or_404(Category, slug=slug)
51
52 def title(self, obj):
53 return "RSS feed - blog posts in category %s" % obj.name
54
55 def link(self, obj):
56 return obj.get_absolute_url()
57
58 def description(self, obj):
59 return "RSS feed - blog posts in category %s" % obj.name
60
61 def items(self, obj):
62 return Post.objects.filter(category=obj).order_by('-pub_date')

Note that many of our fields don't have to be explicitly defined as they are inherited from PostsFeed. We can't hard-code the title, link or description because they depend on the category, so we instead define methods to return the appropriate text.

Also note get_object() - we define this so that we can ensure the category exists. If it doesn't exist, then it returns a 404 error rather than showing an empty feed.

We also override items() to filter it to just those posts that are in the given category.

If you run the tests again, they should now pass:

1$ python manage.py test blogengine
2Creating test database for alias 'default'...
3.........................
4----------------------------------------------------------------------
5Ran 25 tests in 5.867s
6
7OK
8Destroying test database for alias 'default'...

Let's commit our changes:

1$ git add blogengine/
2$ git commit -m 'Implemented category RSS feed'

Now, we can get our category RSS feed, but how do we navigate to it? Let's add a link to each category page that directs a user to its RSS feed. To do so, we'll need to create a new template for category pages. First, let's add some code to our tests to ensure that the right template is used at all times. Add the following to the end of the test_index method of PostViewTest:

1 # Check the correct template was used
2 self.assertTemplateUsed(response, 'blogengine/post_list.html')

Then, add this to the end of test_post_page:

1
2 # Check the correct template was used
3 self.assertTemplateUsed(response, 'blogengine/post_detail.html')

Finally, add this to the end of test_category_page:

1 # Check the correct template was used
2 self.assertTemplateUsed(response, 'blogengine/category_post_list.html')

These assertions confirm which template was used to generate which request.

Next, we head into our views file:

1class CategoryListView(ListView):
2 template_name = 'blogengine/category_post_list.html'
3
4 def get_queryset(self):
5 slug = self.kwargs['slug']
6 try:
7 category = Category.objects.get(slug=slug)
8 return Post.objects.filter(category=category)
9 except Category.DoesNotExist:
10 return Post.objects.none()
11
12 def get_context_data(self, **kwargs):
13 context = super(CategoryListView, self).get_context_data(**kwargs)
14 slug = self.kwargs['slug']
15 try:
16 context['category'] = Category.objects.get(slug=slug)
17 except Category.DoesNotExist:
18 context['category'] = None
19 return context

Note that we first of all change the template used by this view. Then, we override get_context_data to add in additional data. What we're doing is getting the slug that was passed through, looking up any category for which it is the slug, and returning it as additional context data. Using this method, you can easily add additional data that you may wish to render in your Django templates.

Finally, we create our new template:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 {% if object_list %}
7 {% for post in object_list %}
8 <div class="post col-md-12">
9 <h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
10 <h3>{{ post.pub_date }}</h3>
11 {{ post.text|custom_markdown }}
12 </div>
13 {% if post.category %}
14 <div class="col-md-12">
15 <a href="{{ post.category.get_absolute_url }}"><span class="label label-primary">{{ post.category.name }}</span></a>
16 </div>
17 {% endif %}
18 {% if post.tags %}
19 <div class="col-md-12">
20 {% for tag in post.tags.all %}
21 <a href="{{ tag.get_absolute_url }}"><span class="label label-success">{{ tag.name }}</span></a>
22 {% endfor %}
23 </div>
24 {% endif %}
25 {% endfor %}
26 {% else %}
27 <p>No posts found</p>
28 {% endif %}
29
30 <ul class="pager">
31 {% if page_obj.has_previous %}
32 <li class="previous"><a href="/{{ page_obj.previous_page_number }}/">Previous Page</a></li>
33 {% endif %}
34 {% if page_obj.has_next %}
35 <li class="next"><a href="/{{ page_obj.next_page_number }}/">Next Page</a></li>
36 {% endif %}
37 </ul>
38
39 <a href="/feeds/posts/category/{{ category.slug }}/">RSS feed for category {{ category.name }}</a>
40
41 {% endblock %}

Note that the category has been passed through to the template and is now accessible. If you run the tests, they should now pass:

1$ python manage.py jenkins
2Creating test database for alias 'default'...
3.........................
4----------------------------------------------------------------------
5Ran 25 tests in 7.232s
6
7OK
8Destroying test database for alias 'default'...

With that done. we can commit our changes:

1$ git add templates/ blogengine/
2$ git commit -m 'Added link to RSS feed from category page'

Next up, let's implement another RSS feed for tags. First, we'll implement our test:

1 def test_tag_feed(self):
2 # Create a post
3 post = PostFactory(text='This is my *first* blog post')
4 tag = TagFactory()
5 post.tags.add(tag)
6 post.save()
7
8 # Create another post with a different tag
9 tag2 = TagFactory(name='perl', description='The Perl programming language', slug='perl')
10 post2 = PostFactory(text='This is my *second* blog post', title='My second post', slug='my-second-post')
11 post2.tags.add(tag2)
12 post2.save()
13
14 # Fetch the feed
15 response = self.client.get('/feeds/posts/tag/python/')
16 self.assertEquals(response.status_code, 200)
17
18 # Parse the feed
19 feed = feedparser.parse(response.content)
20
21 # Check length
22 self.assertEquals(len(feed.entries), 1)
23
24 # Check post retrieved is the correct one
25 feed_post = feed.entries[0]
26 self.assertEquals(feed_post.title, post.title)
27 self.assertTrue('This is my <em>first</em> blog post' in feed_post.description)
28
29 # Check other post is not in this feed
30 self.assertTrue('This is my <em>second</em> blog post' not in response.content)

This is virtually identical to the test for the category feed, but we adjust it to work with the Tag attribute and change the URL. Let's check that our test fails:

1$ python manage.py test blogengine
2Creating test database for alias 'default'...
3.................F........
4======================================================================
5FAIL: test_tag_feed (blogengine.tests.FeedTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 757, in test_tag_feed
9 self.assertEquals(response.status_code, 200)
10AssertionError: 404 != 200
11
12----------------------------------------------------------------------
13Ran 26 tests in 5.760s
14
15FAILED (failures=1)
16Destroying test database for alias 'default'...

As before, we create a route for this:

1from django.conf.urls import patterns, url
2from django.views.generic import ListView, DetailView
3from blogengine.models import Post, Category, Tag
4from blogengine.views import CategoryListView, TagListView, PostsFeed, CategoryPostsFeed, TagPostsFeed
5
6urlpatterns = patterns('',
7 # Index
8 url(r'^(?P<page>\d+)?/?$', ListView.as_view(
9 model=Post,
10 paginate_by=5,
11 )),
12
13 # Individual posts
14 url(r'^(?P<pub_date__year>\d{4})/(?P<pub_date__month>\d{1,2})/(?P<slug>[a-zA-Z0-9-]+)/?$', DetailView.as_view(
15 model=Post,
16 )),
17
18 # Categories
19 url(r'^category/(?P<slug>[a-zA-Z0-9-]+)/?$', CategoryListView.as_view(
20 paginate_by=5,
21 model=Category,
22 )),
23
24 # Tags
25 url(r'^tag/(?P<slug>[a-zA-Z0-9-]+)/?$', TagListView.as_view(
26 paginate_by=5,
27 model=Tag,
28 )),
29
30 # Post RSS feed
31 url(r'^feeds/posts/$', PostsFeed()),
32
33 # Category RSS feed
34 url(r'^feeds/posts/category/(?P<slug>[a-zA-Z0-9-]+)/?$', CategoryPostsFeed()),
35
36 # Tag RSS feed
37 url(r'^feeds/posts/tag/(?P<slug>[a-zA-Z0-9-]+)/?$', TagPostsFeed()),
38)

Next, we create our view:

1class TagPostsFeed(PostsFeed):
2 def get_object(self, request, slug):
3 return get_object_or_404(Tag, slug=slug)
4
5 def title(self, obj):
6 return "RSS feed - blog posts tagged %s" % obj.name
7
8 def link(self, obj):
9 return obj.get_absolute_url()
10
11 def description(self, obj):
12 return "RSS feed - blog posts tagged %s" % obj.name
13
14 def items(self, obj):
15 try:
16 tag = Tag.objects.get(slug=obj.slug)
17 return tag.post_set.all()
18 except Tag.DoesNotExist:
19 return Post.objects.none()

Again, this inherits from PostsFeed, but the syntax for getting posts matching a tag is slightly different because they use a many-to-many relationship.

We also need a template for the tag pages. Add this to the end of the test_tag_page method:

1
2 # Check the correct template was used
3 self.assertTemplateUsed(response, 'blogengine/tag_post_list.html')

Let's create that template:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 {% if object_list %}
7 {% for post in object_list %}
8 <div class="post col-md-12">
9 <h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
10 <h3>{{ post.pub_date }}</h3>
11 {{ post.text|custom_markdown }}
12 </div>
13 {% if post.category %}
14 <div class="col-md-12">
15 <a href="{{ post.category.get_absolute_url }}"><span class="label label-primary">{{ post.category.name }}</span></a>
16 </div>
17 {% endif %}
18 {% if post.tags %}
19 <div class="col-md-12">
20 {% for tag in post.tags.all %}
21 <a href="{{ tag.get_absolute_url }}"><span class="label label-success">{{ tag.name }}</span></a>
22 {% endfor %}
23 </div>
24 {% endif %}
25 {% endfor %}
26 {% else %}
27 <p>No posts found</p>
28 {% endif %}
29
30 <ul class="pager">
31 {% if page_obj.has_previous %}
32 <li class="previous"><a href="/{{ page_obj.previous_page_number }}/">Previous Page</a></li>
33 {% endif %}
34 {% if page_obj.has_next %}
35 <li class="next"><a href="/{{ page_obj.next_page_number }}/">Next Page</a></li>
36 {% endif %}
37 </ul>
38
39 <a href="/feeds/posts/tag/{{ tag.slug }}/">RSS feed for tag {{ tag.name }}</a>
40
41 {% endblock %}

This is virtually identical to the category template. You'll also need to apply this template in the view for the tag list, and pass the tag name through as context data:

1class TagListView(ListView):
2 template_name = 'blogengine/tag_post_list.html'
3
4 def get_queryset(self):
5 slug = self.kwargs['slug']
6 try:
7 tag = Tag.objects.get(slug=slug)
8 return tag.post_set.all()
9 except Tag.DoesNotExist:
10 return Post.objects.none()
11
12 def get_context_data(self, **kwargs):
13 context = super(TagListView, self).get_context_data(**kwargs)
14 slug = self.kwargs['slug']
15 try:
16 context['tag'] = Tag.objects.get(slug=slug)
17 except Tag.DoesNotExist:
18 context['tag'] = None
19 return context

Let's run our tests:

1$ python manage.py test blogengine
2Creating test database for alias 'default'...
3..........................
4----------------------------------------------------------------------
5Ran 26 tests in 5.770s
6
7OK
8Destroying test database for alias 'default'...

You may want to do a quick check to ensure your tag feed link works as expected. Time to commit:

1$ git add blogengine templates
2$ git commit -m 'Implemented tag feeds'

Moving our templates

Before we crack on with implementing search, there's one more piece of housekeeping. In Django, templates can be applied at project level or at app level. So far, we've been storing them in the project, but we would like our app to be as self-contained as possible so it can just be dropped into future projects where we need a blog. That way, it can be easily overridden for specific projects. You can move the folders and update the Git repository at the same time with this command:

$ git mv templates/ blogengine/

We run the tests to make sure nothing untoward has happened:

1$ python manage.py test
2Creating test database for alias 'default'...
3..........................
4----------------------------------------------------------------------
5Ran 26 tests in 5.847s
6
7OK
8Destroying test database for alias 'default'...

And we commit:

$ git commit -m 'Moved templates'

Note that git mv updates Git and moves the files, so you don't need to call git add.

Implementing search

For our final task today, we will be implementing a very simple search engine. Our requirements are:

  • It should be in the header, to allow for easy access from anywhere in the front end.
  • It should search the title and text of posts.

First, we'll write our tests:

1class SearchViewTest(BaseAcceptanceTest):
2 def test_search(self):
3 # Create a post
4 post = PostFactory()
5
6 # Create another post
7 post2 = PostFactory(text='This is my *second* blog post', title='My second post', slug='my-second-post')
8
9 # Search for first post
10 response = self.client.get('/search?q=first')
11 self.assertEquals(response.status_code, 200)
12
13 # Check the first post is contained in the results
14 self.assertTrue('My first post' in response.content)
15
16 # Check the second post is not contained in the results
17 self.assertTrue('My second post' not in response.content)
18
19 # Search for second post
20 response = self.client.get('/search?q=second')
21 self.assertEquals(response.status_code, 200)
22
23 # Check the first post is not contained in the results
24 self.assertTrue('My first post' not in response.content)
25
26 # Check the second post is contained in the results
27 self.assertTrue('My second post' in response.content)

Don't forget to run the tests to make sure they fail:

1$ python manage.py test
2Creating test database for alias 'default'...
3..........................F
4======================================================================
5FAIL: test_search (blogengine.tests.SearchViewTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 819, in test_search
9 self.assertEquals(response.status_code, 200)
10AssertionError: 404 != 200
11
12----------------------------------------------------------------------
13Ran 27 tests in 6.919s
14
15FAILED (failures=1)
16Destroying test database for alias 'default'...

With that done, we can add the search form to the header:

1<!DOCTYPE html>
2<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
3<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
4<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
5<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
6 <head>
7 <meta charset="utf-8">
8 <meta http-equiv="X-UA-Compatible" content="IE=edge">
9 <title>{% block title %}My Django Blog{% endblock %}</title>
10 <meta name="description" content="">
11 <meta name="viewport" content="width=device-width, initial-scale=1">
12 <link rel="alternate" type="application/rss+xml" title="Blog posts" href="/feeds/posts/" >
13
14 <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
15
16 {% load staticfiles %}
17 <link rel="stylesheet" href="{% static 'bower_components/html5-boilerplate/css/normalize.css' %}">
18 <link rel="stylesheet" href="{% static 'bower_components/html5-boilerplate/css/main.css' %}">
19 <link rel="stylesheet" href="{% static 'bower_components/bootstrap/dist/css/bootstrap.min.css' %}">
20 <link rel="stylesheet" href="{% static 'bower_components/bootstrap/dist/css/bootstrap-theme.min.css' %}">
21 <link rel="stylesheet" href="{% static 'css/main.css' %}">
22 <link rel="stylesheet" href="{% static 'css/code.css' %}">
23 <script src="{% static 'bower_components/html5-boilerplate/js/vendor/modernizr-2.6.2.min.js' %}"></script>
24 </head>
25 <body>
26 <!--[if lt IE 7]>
27 <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>
28 <![endif]-->
29
30 <!-- Add your site or application content here -->
31
32 <div id="fb-root"></div>
33 <script>(function(d, s, id) {
34 var js, fjs = d.getElementsByTagName(s)[0];
35 if (d.getElementById(id)) return;
36 js = d.createElement(s); js.id = id;
37 js.src = "//connect.facebook.net/en_GB/all.js#xfbml=1";
38 fjs.parentNode.insertBefore(js, fjs);
39 }(document, 'script', 'facebook-jssdk'));</script>
40
41 <div class="navbar navbar-static-top navbar-inverse">
42 <div class="container-fluid">
43 <div class="navbar-header">
44 <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#header-nav">
45 <span class="icon-bar"></span>
46 <span class="icon-bar"></span>
47 <span class="icon-bar"></span>
48 </button>
49 <a class="navbar-brand" href="/">My Django Blog</a>
50 </div>
51 <div class="collapse navbar-collapse" id="header-nav">
52 <ul class="nav navbar-nav">
53 {% load flatpages %}
54 {% get_flatpages as flatpages %}
55 {% for flatpage in flatpages %}
56 <li><a href="{{ flatpage.url }}">{{ flatpage.title }}</a></li>
57 {% endfor %}
58 <li><a href="/feeds/posts/">RSS feed</a></li>
59
60 <form action="/search" method="GET" class="navbar-form navbar-left">
61 <div class="form-group">
62 <input type="text" name="q" placeholder="Search..." class="form-control"></input>
63 </div>
64 <button type="submit" class="btn btn-default">Search</button>
65 </form>
66 </ul>
67 </div>
68 </div>
69 </div>
70
71 <div class="container">
72 {% block header %}
73 <div class="page-header">
74 <h1>My Django Blog</h1>
75 </div>
76 {% endblock %}
77
78 <div class="row">
79 {% block content %}{% endblock %}
80 </div>
81 </div>
82
83 <div class="container footer">
84 <div class="row">
85 <div class="span12">
86 <p>Copyright &copy; {% now "Y" %}</p>
87 </div>
88 </div>
89 </div>
90
91 <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
92 <script>window.jQuery || document.write('<script src="{% static 'bower_components/html5-boilerplate/js/vendor/jquery-1.10.2.min.js' %}"><\/script>')</script>
93 <script src="{% static 'bower_components/html5-boilerplate/js/plugins.js' %}"></script>
94 <script src="{% static 'bower_components/bootstrap/dist/js/bootstrap.min.js' %}"></script>
95
96 <!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
97 <script>
98 (function(b,o,i,l,e,r){b.GoogleAnalyticsObject=l;b[l]||(b[l]=
99 function(){(b[l].q=b[l].q||[]).push(arguments)});b[l].l=+new Date;
100 e=o.createElement(i);r=o.getElementsByTagName(i)[0];
101 e.src='//www.google-analytics.com/analytics.js';
102 r.parentNode.insertBefore(e,r)}(window,document,'script','ga'));
103 ga('create','UA-XXXXX-X');ga('send','pageview');
104 </script>
105 </body>
106</html>

Now we'll actually implement our search. Implementing search using Django's generic views can be fiddly, so we'll write our search view as a function instead. First, amend the imports at the top of your view file to look like this:

1from django.shortcuts import get_object_or_404, render_to_response
2from django.core.paginator import Paginator, EmptyPage
3from django.db.models import Q
4from django.views.generic import ListView
5from blogengine.models import Category, Post, Tag
6from django.contrib.syndication.views import Feed
7from django.utils.encoding import force_unicode
8from django.utils.safestring import mark_safe
9import markdown2

Next, add the following code to the end of the file:

1def getSearchResults(request):
2 """
3 Search for a post by title or text
4 """
5 # Get the query data
6 query = request.GET.get('q', '')
7 page = request.GET.get('page', 1)
8
9 # Query the database
10 if query:
11 results = Post.objects.filter(Q(text__icontains=query) | Q(title__icontains=query))
12 else:
13 results = None
14
15 # Add pagination
16 pages = Paginator(results, 5)
17
18 # Get specified page
19 try:
20 returned_page = pages.page(page)
21 except EmptyPage:
22 returned_page = pages.page(pages.num_pages)
23
24 # Display the search results
25 return render_to_response('blogengine/search_post_list.html',
26 {'page_obj': returned_page,
27 'object_list': returned_page.object_list,
28 'search': query})

As this is the first time we've written a view without using generic views, a little explanation is called for. First we get the values of the q and page parameters passed to the view. q contains the query text and page contains the page number. Note also that our page defaults to 1 if not set.

We then use the Q object to perform a query. The Django ORM will AND together keyword argument queries, but that's not the behaviour we want here. Instead we want to be able to search for content in the title or text, so we need to use a query with an OR statement, which necessitates using the Q object.

Next, we use the Paginator object to manually paginate the results, and if it raises an EmptyPage exception, to just show the last page instead. Finally we render the template blogengine/search_post_list.html, and pass through the parameters page_obj for the returned page, object_list for the objects, and search for the query.

We also need to add a route for our new view:

1
2 # Search posts
3 url(r'^search', 'blogengine.views.getSearchResults'),

Finally, let's create a new template to show our results:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 {% if object_list %}
7 {% for post in object_list %}
8 <div class="post col-md-12">
9 <h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
10 <h3>{{ post.pub_date }}</h3>
11 {{ post.text|custom_markdown }}
12 </div>
13 {% if post.category %}
14 <div class="col-md-12">
15 <a href="{{ post.category.get_absolute_url }}"><span class="label label-primary">{{ post.category.name }}</span></a>
16 </div>
17 {% endif %}
18 {% if post.tags %}
19 <div class="col-md-12">
20 {% for tag in post.tags.all %}
21 <a href="{{ tag.get_absolute_url }}"><span class="label label-success">{{ tag.name }}</span></a>
22 {% endfor %}
23 </div>
24 {% endif %}
25 {% endfor %}
26 {% else %}
27 <p>No posts found</p>
28 {% endif %}
29
30 <ul class="pager">
31 {% if page_obj.has_previous %}
32 <li class="previous"><a href="/search?page={{ page_obj.previous_page_number }}&q={{ search }}">Previous Page</a></li>
33 {% endif %}
34 {% if page_obj.has_next %}
35 <li class="next"><a href="/search?page={{ page_obj.next_page_number }}&q={{ search }}">Next Page</a></li>
36 {% endif %}
37 </ul>
38
39 {% endblock %}

Let's run our tests:

1$ python manage.py test
2Creating test database for alias 'default'...
3...........................
4----------------------------------------------------------------------
5Ran 27 tests in 6.421s
6
7OK
8Destroying test database for alias 'default'...

Don't forget to do a quick sense check to make sure it's all working as expected. Then it's time to commit:

1$ git add blogengine/
2$ git commit -m 'Implemented search'

And push up your changes:

1$ git push origin master
2$ git push heroku master

And that's the end of this instalment. Please note this particular search solution is quite basic, and if you want something more powerful, you may want to look at Haystack.

As usual, you can get this lesson with git checkout lesson-7 - if you have any problems, the repository should be the first place you look for answers as this is the working code base for the application, and with judicious use of a tool like diff, it's generally pretty easy to track down most issues.

In our next, and final instalment, we'll cover:

  • Tidying everything up
  • Implementing an XML sitemap for search engines
  • Optimising our site
  • Using Fabric to make deployment easier

Hope to see you then!