Django blog tutorial - the next generation - part 2

Published by at 2nd January 2014 11:28 am

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:

1{
2 "directory": "blogengine/static/bower_components"
3}

This tells Bower where it should put downloaded libraries. Next, run the following command to generate the config for 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:

1$ git add .gitignore .bowerrc bower.json
2$ 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:

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
13 <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
14
15 {% load staticfiles %}
16 <link rel="stylesheet" href="{% static 'bower_components/html5-boilerplate/css/normalize.css' %}">
17 <link rel="stylesheet" href="{% static 'bower_components/html5-boilerplate/css/main.css' %}">
18 <link rel="stylesheet" href="{% static 'bower_components/bootstrap/dist/css/bootstrap.min.css' %}">
19 <link rel="stylesheet" href="{% static 'bower_components/bootstrap/dist/css/bootstrap-theme.min.css' %}">
20 <script src="{% static 'bower_components/html5-boilerplate/js/vendor/modernizr-2.6.2.min.js' %}"></script>
21 </head>
22 <body>
23 <!--[if lt IE 7]>
24 <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>
25 <![endif]-->
26
27 <!-- Add your site or application content here -->
28 {% block content %}{% endblock %}
29
30 <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
31 <script>window.jQuery || document.write('<script src="{% static 'bower_components/html5-boilerplate/js/vendor/jquery-1.10.2.min.js' %}"><\/script>')</script>
32 <script src="{% static 'bower_components/html5-boilerplate/js/plugins.js' %}"></script>
33 <script src="{% static 'bower_components/bootstrap/dist/js/bootstrap.min.js' %}"></script>
34
35 <!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
36 <script>
37 (function(b,o,i,l,e,r){b.GoogleAnalyticsObject=l;b[l]||(b[l]=
38 function(){(b[l].q=b[l].q||[]).push(arguments)});b[l].l=+new Date;
39 e=o.createElement(i);r=o.getElementsByTagName(i)[0];
40 e.src='//www.google-analytics.com/analytics.js';
41 r.parentNode.insertBefore(e,r)}(window,document,'script','ga'));
42 ga('create','UA-XXXXX-X');ga('send','pageview');
43 </script>
44 </body>
45</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:

1{% extends "blogengine/includes/base.html" %}
2
3 {% block content %}
4 {% for post in object_list %}
5 <h1>{{ post.title }}</h1>
6 <h3>{{ post.pub_date }}</h3>
7 {{ post.text }}
8 {% endfor %}
9 {% 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:

1$ git add templates/
2$ 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:

1 <div class="navbar navbar-static-top navbar-inverse">
2 <div class="navbar-inner">
3 <div class="container">
4 <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
5 <span class="icon-bar"></span>
6 <span class="icon-bar"></span>
7 <span class="icon-bar"></span>
8 </a>
9 <a class="brand" href="/">My Django Blog</a>
10 <div class="nav-collapse collapse">
11 </div>
12 </div>
13 </div>
14 </div>
15
16 <div class="container">
17 {% block header %}
18 <div class="page-header">
19 <h1>My Django Blog</h1>
20 </div>
21 {% endblock %}
22
23 <div class="row">
24 {% block content %}{% endblock %}
25 </div>
26 </div>
27
28 <div class="container footer">
29 <div class="row">
30 <div class="span12">
31 <p>Copyright &copy; {% now "Y" %}</p>
32 </div>
33 </div>
34 </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:

1{% extends "blogengine/includes/base.html" %}
2
3 {% block content %}
4 {% for post in object_list %}
5 <div class="post">
6 <h1>{{ post.title }}</h1>
7 <h3>{{ post.pub_date }}</h3>
8 {{ post.text }}
9 </div>
10 {% endfor %}
11 {% endblock %}

Let's commit our changes:

1$ git add templates/
2$ 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:

1$ pip install markdown
2$ 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:

1class PostViewTest(LiveServerTestCase):
2 def setUp(self):
3 self.client = Client()
4
5 def test_index(self):
6 # Create the post
7 post = Post()
8 post.title = 'My first post'
9 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
10 post.pub_date = timezone.now()
11 post.save()
12
13 # Check new post saved
14 all_posts = Post.objects.all()
15 self.assertEquals(len(all_posts), 1)
16
17 # Fetch the index
18 response = self.client.get('/')
19 self.assertEquals(response.status_code, 200)
20
21 # Check the post title is in the response
22 self.assertTrue(post.title in response.content)
23
24 # Check the post text is in the response
25 self.assertTrue(markdown.markdown(post.text) in response.content)
26
27 # Check the post date is in the response
28 self.assertTrue(str(post.pub_date.year) in response.content)
29 self.assertTrue(post.pub_date.strftime('%b') in response.content)
30 self.assertTrue(str(post.pub_date.day) in response.content)
31
32 # Check the link is marked up properly
33 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:

1$ mkdir blogengine/templatetags
2$ 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:

1import markdown
2
3from django import template
4from django.template.defaultfilters import stringfilter
5from django.utils.encoding import force_unicode
6from django.utils.safestring import mark_safe
7
8register = template.Library()
9
10@register.filter(is_safe=True)
11@stringfilter
12def custom_markdown(value):
13 extensions = ["nl2br", ]
14
15 return mark_safe(markdown.markdown(force_unicode(value),
16 extensions,
17 safe_mode=True,
18 enable_attributes=False))

Then just amend the post list template to use it:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 {% for post in object_list %}
7 <div class="post">
8 <h1>{{ post.title }}</h1>
9 <h3>{{ post.pub_date }}</h3>
10 {{ post.text|custom_markdown }}
11 </div>
12 {% endfor %}
13 {% endblock %}

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

Let's commit the changes:

1$ git add requirements.txt templates/ blogengine/
2$ 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:

1from django.conf.urls import patterns, url
2from django.views.generic import ListView
3from blogengine.models import Post
4
5urlpatterns = patterns('',
6 # Index
7 url(r'^(?P<page>\d+)?/?$', ListView.as_view(
8 model=Post,
9 paginate_by=5,
10 )),
11)

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:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 {% for post in object_list %}
7 <div class="post">
8 <h1>{{ post.title }}</h1>
9 <h3>{{ post.pub_date }}</h3>
10 {{ post.text|custom_markdown }}
11 </div>
12 {% endfor %}
13
14 {% if page_obj.has_previous %}
15 <a href="/{{ page_obj.previous_page_number }}/">Previous Page</a>
16 {% endif %}
17 {% if page_obj.has_next %}
18 <a href="/{{ page_obj.next_page_number }}/">Next Page</a>
19 {% endif %}
20
21 {% 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:

1from django.conf.urls import patterns, include, url
2
3from django.contrib import admin
4admin.autodiscover()
5
6urlpatterns = patterns('',
7 # Examples:
8 # url(r'^$', 'django_tutorial_blog_ng.views.home', name='home'),
9 # url(r'^blog/', include('blog.urls')),
10
11 url(r'^admin/', include(admin.site.urls)),
12
13 # Blog URLs
14 url(r'', include('blogengine.urls')),
15)

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

1$ git add blogengine/ django_tutorial_blog_ng/ templates/
2$ 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:

1 def test_post_page(self):
2 # Create the post
3 post = Post()
4 post.title = 'My first post'
5 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
6 post.pub_date = timezone.now()
7 post.save()
8
9 # Check new post saved
10 all_posts = Post.objects.all()
11 self.assertEquals(len(all_posts), 1)
12 only_post = all_posts[0]
13 self.assertEquals(only_post, post)
14
15 # Get the post URL
16 post_url = only_post.get_absolute_url()
17
18 # Fetch the post
19 response = self.client.get(post_url)
20 self.assertEquals(response.status_code, 200)
21
22 # Check the post title is in the response
23 self.assertTrue(post.title in response.content)
24
25 # Check the post text is in the response
26 self.assertTrue(markdown.markdown(post.text) in response.content)
27
28 # Check the post date is in the response
29 self.assertTrue(str(post.pub_date.year) in response.content)
30 self.assertTrue(post.pub_date.strftime('%b') in response.content)
31 self.assertTrue(str(post.pub_date.day) in response.content)
32
33 # Check the link is marked up properly
34 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:

1from django.db import models
2
3# Create your models here.
4class Post(models.Model):
5 title = models.CharField(max_length=200)
6 pub_date = models.DateTimeField()
7 text = models.TextField()
8 slug = models.SlugField(max_length=40, unique=True)
9
10 def get_absolute_url(self):
11 return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
12
13 def __unicode__(self):
14 return self.title
15
16 class Meta:
17 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:

1import models
2from django.contrib import admin
3
4class PostAdmin(admin.ModelAdmin):
5 prepopulated_fields = {"slug": ("title",)}
6
7admin.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:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
2Creating test database for alias 'default'...
3.F.F...F
4======================================================================
5FAIL: test_create_post (blogengine.tests.AdminTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 103, in test_create_post
9 self.assertTrue('added successfully' in response.content)
10AssertionError: False is not true
11
12======================================================================
13FAIL: test_edit_post (blogengine.tests.AdminTest)
14----------------------------------------------------------------------
15Traceback (most recent call last):
16 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 132, in test_edit_post
17 self.assertTrue('changed successfully' in response.content)
18AssertionError: False is not true
19
20======================================================================
21FAIL: test_post_page (blogengine.tests.PostViewTest)
22----------------------------------------------------------------------
23Traceback (most recent call last):
24 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 222, in test_post_page
25 self.assertEquals(response.status_code, 200)
26AssertionError: 404 != 200
27
28----------------------------------------------------------------------
29Ran 8 tests in 2.180s
30
31FAILED (failures=3)
32Destroying 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:

1class PostTest(TestCase):
2 def test_create_post(self):
3 # Create the post
4 post = Post()
5
6 # Set the attributes
7 post.title = 'My first post'
8 post.text = 'This is my first blog post'
9 post.slug = 'my-first-post'
10 post.pub_date = timezone.now()
11
12 # Save it
13 post.save()
14
15 # Check we can find it
16 all_posts = Post.objects.all()
17 self.assertEquals(len(all_posts), 1)
18 only_post = all_posts[0]
19 self.assertEquals(only_post, post)
20
21 # Check attributes
22 self.assertEquals(only_post.title, 'My first post')
23 self.assertEquals(only_post.text, 'This is my first blog post')
24 self.assertEquals(only_post.slug, 'my-first-post')
25 self.assertEquals(only_post.pub_date.day, post.pub_date.day)
26 self.assertEquals(only_post.pub_date.month, post.pub_date.month)
27 self.assertEquals(only_post.pub_date.year, post.pub_date.year)
28 self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
29 self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
30 self.assertEquals(only_post.pub_date.second, post.pub_date.second)

Next, let's amend AdminTest:

1class AdminTest(LiveServerTestCase):
2 fixtures = ['users.json']
3
4 def setUp(self):
5 self.client = Client()
6
7 def test_login(self):
8 # Get login page
9 response = self.client.get('/admin/')
10
11 # Check response code
12 self.assertEquals(response.status_code, 200)
13
14 # Check 'Log in' in response
15 self.assertTrue('Log in' in response.content)
16
17 # Log the user in
18 self.client.login(username='bobsmith', password="password")
19
20 # Check response code
21 response = self.client.get('/admin/')
22 self.assertEquals(response.status_code, 200)
23
24 # Check 'Log out' in response
25 self.assertTrue('Log out' in response.content)
26
27 def test_logout(self):
28 # Log in
29 self.client.login(username='bobsmith', password="password")
30
31 # Check response code
32 response = self.client.get('/admin/')
33 self.assertEquals(response.status_code, 200)
34
35 # Check 'Log out' in response
36 self.assertTrue('Log out' in response.content)
37
38 # Log out
39 self.client.logout()
40
41 # Check response code
42 response = self.client.get('/admin/')
43 self.assertEquals(response.status_code, 200)
44
45 # Check 'Log in' in response
46 self.assertTrue('Log in' in response.content)
47
48 def test_create_post(self):
49 # Log in
50 self.client.login(username='bobsmith', password="password")
51
52 # Check response code
53 response = self.client.get('/admin/blogengine/post/add/')
54 self.assertEquals(response.status_code, 200)
55
56 # Create the new post
57 response = self.client.post('/admin/blogengine/post/add/', {
58 'title': 'My first post',
59 'text': 'This is my first post',
60 'pub_date_0': '2013-12-28',
61 'pub_date_1': '22:00:04',
62 'slug': 'my-first-post'
63 },
64 follow=True
65 )
66 self.assertEquals(response.status_code, 200)
67
68 # Check added successfully
69 self.assertTrue('added successfully' in response.content)
70
71 # Check new post now in database
72 all_posts = Post.objects.all()
73 self.assertEquals(len(all_posts), 1)
74
75 def test_edit_post(self):
76 # Create the post
77 post = Post()
78 post.title = 'My first post'
79 post.text = 'This is my first blog post'
80 post.slug = 'my-first-post'
81 post.pub_date = timezone.now()
82 post.save()
83
84 # Log in
85 self.client.login(username='bobsmith', password="password")
86
87 # Edit the post
88 response = self.client.post('/admin/blogengine/post/1/', {
89 'title': 'My second post',
90 'text': 'This is my second blog post',
91 'pub_date_0': '2013-12-28',
92 'pub_date_1': '22:00:04',
93 'slug': 'my-second-post'
94 },
95 follow=True
96 )
97 self.assertEquals(response.status_code, 200)
98
99 # Check changed successfully
100 self.assertTrue('changed successfully' in response.content)
101
102 # Check post amended
103 all_posts = Post.objects.all()
104 self.assertEquals(len(all_posts), 1)
105 only_post = all_posts[0]
106 self.assertEquals(only_post.title, 'My second post')
107 self.assertEquals(only_post.text, 'This is my second blog post')
108
109 def test_delete_post(self):
110 # Create the post
111 post = Post()
112 post.title = 'My first post'
113 post.text = 'This is my first blog post'
114 post.slug = 'my-first-post'
115 post.pub_date = timezone.now()
116 post.save()
117
118 # Check new post saved
119 all_posts = Post.objects.all()
120 self.assertEquals(len(all_posts), 1)
121
122 # Log in
123 self.client.login(username='bobsmith', password="password")
124
125 # Delete the post
126 response = self.client.post('/admin/blogengine/post/1/delete/', {
127 'post': 'yes'
128 }, follow=True)
129 self.assertEquals(response.status_code, 200)
130
131 # Check deleted successfully
132 self.assertTrue('deleted successfully' in response.content)
133
134 # Check post amended
135 all_posts = Post.objects.all()
136 self.assertEquals(len(all_posts), 0)

And PostViewTest:

1class PostViewTest(LiveServerTestCase):
2 def setUp(self):
3 self.client = Client()
4
5 def test_index(self):
6 # Create the post
7 post = Post()
8 post.title = 'My first post'
9 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
10 post.slug = 'my-first-post'
11 post.pub_date = timezone.now()
12 post.save()
13
14 # Check new post saved
15 all_posts = Post.objects.all()
16 self.assertEquals(len(all_posts), 1)
17
18 # Fetch the index
19 response = self.client.get('/')
20 self.assertEquals(response.status_code, 200)
21
22 # Check the post title is in the response
23 self.assertTrue(post.title in response.content)
24
25 # Check the post text is in the response
26 self.assertTrue(markdown.markdown(post.text) in response.content)
27
28 # Check the post date is in the response
29 self.assertTrue(str(post.pub_date.year) in response.content)
30 self.assertTrue(post.pub_date.strftime('%b') in response.content)
31 self.assertTrue(str(post.pub_date.day) in response.content)
32
33 # Check the link is marked up properly
34 self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
35
36 def test_post_page(self):
37 # Create the post
38 post = Post()
39 post.title = 'My first post'
40 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
41 post.slug = 'my-first-post'
42 post.pub_date = timezone.now()
43 post.save()
44
45 # Check new post saved
46 all_posts = Post.objects.all()
47 self.assertEquals(len(all_posts), 1)
48 only_post = all_posts[0]
49 self.assertEquals(only_post, post)
50
51 # Get the post URL
52 post_url = only_post.get_absolute_url()
53
54 # Fetch the post
55 response = self.client.get(post_url)
56 self.assertEquals(response.status_code, 200)
57
58 # Check the post title is in the response
59 self.assertTrue(post.title in response.content)
60
61 # Check the post text is in the response
62 self.assertTrue(markdown.markdown(post.text) in response.content)
63
64 # Check the post date is in the response
65 self.assertTrue(str(post.pub_date.year) in response.content)
66 self.assertTrue(post.pub_date.strftime('%b') in response.content)
67 self.assertTrue(str(post.pub_date.day) in response.content)
68
69 # Check the link is marked up properly
70 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 attribute 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:

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

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

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 <div class="post">
7 <h1>{{ object.title }}</h1>
8 <h3>{{ object.pub_date }}</h3>
9 {{ object.text|custom_markdown }}
10 </div>
11
12{% 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:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 {% for post in object_list %}
7 <div class="post">
8 <h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
9 <h3>{{ post.pub_date }}</h3>
10 {{ post.text|custom_markdown }}
11 </div>
12 {% endfor %}
13
14 {% if page_obj.has_previous %}
15 <a href="/{{ page_obj.previous_page_number }}/">Previous Page</a>
16 {% endif %}
17 {% if page_obj.has_next %}
18 <a href="/{{ page_obj.next_page_number }}/">Next Page</a>
19 {% endif %}
20
21 {% 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:

1$ git add blogengine/ templates/
2$ 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.