Django blog tutorial - the next generation - part 2
Published by Matthew Daly 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.json2$ 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">1213 <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->1415 {% 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]-->2627 <!-- Add your site or application content here -->28 {% block content %}{% endblock %}2930 <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>3435 <!-- 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
andcontent
. 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" %}23 {% 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>1516 <div class="container">17 {% block header %}18 <div class="page-header">19 <h1>My Django Blog</h1>20 </div>21 {% endblock %}2223 <div class="row">24 {% block content %}{% endblock %}25 </div>26 </div>2728 <div class="container footer">29 <div class="row">30 <div class="span12">31 <p>Copyright © {% 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" %}23 {% 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 markdown2$ 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()45 def test_index(self):6 # Create the post7 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()1213 # Check new post saved14 all_posts = Post.objects.all()15 self.assertEquals(len(all_posts), 1)1617 # Fetch the index18 response = self.client.get('/')19 self.assertEquals(response.status_code, 200)2021 # Check the post title is in the response22 self.assertTrue(post.title in response.content)2324 # Check the post text is in the response25 self.assertTrue(markdown.markdown(post.text) in response.content)2627 # Check the post date is in the response28 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)3132 # Check the link is marked up properly33 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/templatetags2$ 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 markdown23from django import template4from django.template.defaultfilters import stringfilter5from django.utils.encoding import force_unicode6from django.utils.safestring import mark_safe78register = template.Library()910@register.filter(is_safe=True)11@stringfilter12def custom_markdown(value):13 extensions = ["nl2br", ]1415 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" %}23 {% load custom_markdown %}45 {% 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, url2from django.views.generic import ListView3from blogengine.models import Post45urlpatterns = patterns('',6 # Index7 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" %}23 {% load custom_markdown %}45 {% 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 %}1314 {% 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 %}2021 {% 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, url23from django.contrib import admin4admin.autodiscover()56urlpatterns = patterns('',7 # Examples:8 # url(r'^$', 'django_tutorial_blog_ng.views.home', name='home'),9 # url(r'^blog/', include('blog.urls')),1011 url(r'^admin/', include(admin.site.urls)),1213 # Blog URLs14 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 post3 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()89 # Check new post saved10 all_posts = Post.objects.all()11 self.assertEquals(len(all_posts), 1)12 only_post = all_posts[0]13 self.assertEquals(only_post, post)1415 # Get the post URL16 post_url = only_post.get_absolute_url()1718 # Fetch the post19 response = self.client.get(post_url)20 self.assertEquals(response.status_code, 200)2122 # Check the post title is in the response23 self.assertTrue(post.title in response.content)2425 # Check the post text is in the response26 self.assertTrue(markdown.markdown(post.text) in response.content)2728 # Check the post date is in the response29 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)3233 # Check the link is marked up properly34 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 models23# 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)910 def get_absolute_url(self):11 return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)1213 def __unicode__(self):14 return self.title1516 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 models2from django.contrib import admin34class PostAdmin(admin.ModelAdmin):5 prepopulated_fields = {"slug": ("title",)}67admin.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 test2Creating test database for alias 'default'...3.F.F...F4======================================================================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_post9 self.assertTrue('added successfully' in response.content)10AssertionError: False is not true1112======================================================================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_post17 self.assertTrue('changed successfully' in response.content)18AssertionError: False is not true1920======================================================================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_page25 self.assertEquals(response.status_code, 200)26AssertionError: 404 != 2002728----------------------------------------------------------------------29Ran 8 tests in 2.180s3031FAILED (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 post4 post = Post()56 # Set the attributes7 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()1112 # Save it13 post.save()1415 # Check we can find it16 all_posts = Post.objects.all()17 self.assertEquals(len(all_posts), 1)18 only_post = all_posts[0]19 self.assertEquals(only_post, post)2021 # Check attributes22 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']34 def setUp(self):5 self.client = Client()67 def test_login(self):8 # Get login page9 response = self.client.get('/admin/')1011 # Check response code12 self.assertEquals(response.status_code, 200)1314 # Check 'Log in' in response15 self.assertTrue('Log in' in response.content)1617 # Log the user in18 self.client.login(username='bobsmith', password="password")1920 # Check response code21 response = self.client.get('/admin/')22 self.assertEquals(response.status_code, 200)2324 # Check 'Log out' in response25 self.assertTrue('Log out' in response.content)2627 def test_logout(self):28 # Log in29 self.client.login(username='bobsmith', password="password")3031 # Check response code32 response = self.client.get('/admin/')33 self.assertEquals(response.status_code, 200)3435 # Check 'Log out' in response36 self.assertTrue('Log out' in response.content)3738 # Log out39 self.client.logout()4041 # Check response code42 response = self.client.get('/admin/')43 self.assertEquals(response.status_code, 200)4445 # Check 'Log in' in response46 self.assertTrue('Log in' in response.content)4748 def test_create_post(self):49 # Log in50 self.client.login(username='bobsmith', password="password")5152 # Check response code53 response = self.client.get('/admin/blogengine/post/add/')54 self.assertEquals(response.status_code, 200)5556 # Create the new post57 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=True65 )66 self.assertEquals(response.status_code, 200)6768 # Check added successfully69 self.assertTrue('added successfully' in response.content)7071 # Check new post now in database72 all_posts = Post.objects.all()73 self.assertEquals(len(all_posts), 1)7475 def test_edit_post(self):76 # Create the post77 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()8384 # Log in85 self.client.login(username='bobsmith', password="password")8687 # Edit the post88 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=True96 )97 self.assertEquals(response.status_code, 200)9899 # Check changed successfully100 self.assertTrue('changed successfully' in response.content)101102 # Check post amended103 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')108109 def test_delete_post(self):110 # Create the post111 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()117118 # Check new post saved119 all_posts = Post.objects.all()120 self.assertEquals(len(all_posts), 1)121122 # Log in123 self.client.login(username='bobsmith', password="password")124125 # Delete the post126 response = self.client.post('/admin/blogengine/post/1/delete/', {127 'post': 'yes'128 }, follow=True)129 self.assertEquals(response.status_code, 200)130131 # Check deleted successfully132 self.assertTrue('deleted successfully' in response.content)133134 # Check post amended135 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()45 def test_index(self):6 # Create the post7 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()1314 # Check new post saved15 all_posts = Post.objects.all()16 self.assertEquals(len(all_posts), 1)1718 # Fetch the index19 response = self.client.get('/')20 self.assertEquals(response.status_code, 200)2122 # Check the post title is in the response23 self.assertTrue(post.title in response.content)2425 # Check the post text is in the response26 self.assertTrue(markdown.markdown(post.text) in response.content)2728 # Check the post date is in the response29 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)3233 # Check the link is marked up properly34 self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)3536 def test_post_page(self):37 # Create the post38 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()4445 # Check new post saved46 all_posts = Post.objects.all()47 self.assertEquals(len(all_posts), 1)48 only_post = all_posts[0]49 self.assertEquals(only_post, post)5051 # Get the post URL52 post_url = only_post.get_absolute_url()5354 # Fetch the post55 response = self.client.get(post_url)56 self.assertEquals(response.status_code, 200)5758 # Check the post title is in the response59 self.assertTrue(post.title in response.content)6061 # Check the post text is in the response62 self.assertTrue(markdown.markdown(post.text) in response.content)6364 # Check the post date is in the response65 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)6869 # Check the link is marked up properly70 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, url2from django.views.generic import ListView, DetailView3from blogengine.models import Post45urlpatterns = patterns('',6 # Index7 url(r'^(?P<page>\d+)?/?$', ListView.as_view(8 model=Post,9 paginate_by=5,10 )),1112 # Individual posts13 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" %}23 {% load custom_markdown %}45 {% block content %}6 <div class="post">7 <h1>{{ object.title }}</h1>8 <h3>{{ object.pub_date }}</h3>9 {{ object.text|custom_markdown }}10 </div>1112{% 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" %}23 {% load custom_markdown %}45 {% 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 %}1314 {% 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 %}2021 {% 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.