Django Blog Tutorial - the Next Generation - Part 6

Published by at 25th May 2014 4:23 pm

Welcome back! In this tutorial we'll cover the following:

  • Fixing bugs the TDD way
  • Setting up syntax highlighting for code snippets
  • Tidying up the front end

Apologies, but I'm holding over implementing the search and additional feeds for a future instalment - in the past I've tried to cover too much in one post and that has led to me putting them off for much too long. So this instalment and future ones are going to be shorter so I can get them out the door quicker.

Ready? Let's get started!

Fixing bugs

When someone reports a bug, it's tempting to just dive straight in and start fixing it. But TDD cautions us against this practice. If we have a bug, then our tests should have caught it. If they don't, then before we fix the bug, we have to make sure we can catch it if it crops up in future by implementing a test for it, and ensuring that it fails. Once that test is in place, we can then go ahead and fix the bug, safe in the knowledge that if it should reappear in future, we will be warned when our tests run.

As it happens, we have a bug in our web app. If you activate your virtualenv in the usual way and run the development server, and then try to create a new post without adding a tag, you'll see that it fails as the tag is empty. Now, it's pretty obvious that this is because the tags attribute of the Post model cannot be blank, so we have a good idea of what we need to do to fix this. But to make sure it never occurs again, we need to implement a test first.

Add the following method to AdminTest:

1 def test_create_post_without_tag(self):
2 # Create the category
3 category = Category()
4 category.name = 'python'
5 category.description = 'The Python programming language'
6 category.save()
7
8 # Log in
9 self.client.login(username='bobsmith', password="password")
10
11 # Check response code
12 response = self.client.get('/admin/blogengine/post/add/')
13 self.assertEquals(response.status_code, 200)
14
15 # Create the new post
16 response = self.client.post('/admin/blogengine/post/add/', {
17 'title': 'My first post',
18 'text': 'This is my first post',
19 'pub_date_0': '2013-12-28',
20 'pub_date_1': '22:00:04',
21 'slug': 'my-first-post',
22 'site': '1',
23 'category': '1'
24 },
25 follow=True
26 )
27 self.assertEquals(response.status_code, 200)
28
29 # Check added successfully
30 self.assertTrue('added successfully' in response.content)
31
32 # Check new post now in database
33 all_posts = Post.objects.all()
34 self.assertEquals(len(all_posts), 1)

This is virtually identical to our previous method for adding a post, but doesn't add a tag to the post. If you run python manage.py jenkins, the test should fail. Let's commit our changes:

1$ git add blogengine/tests.py
2$ git commit -m 'Added failing test for posts without tags'

Now we're in a position to fix our bug. Let's take a look at our models:

1class Post(models.Model):
2 title = models.CharField(max_length=200)
3 pub_date = models.DateTimeField()
4 text = models.TextField()
5 slug = models.SlugField(max_length=40, unique=True)
6 author = models.ForeignKey(User)
7 site = models.ForeignKey(Site)
8 category = models.ForeignKey(Category, blank=True, null=True)
9 tags = models.ManyToManyField(Tag)

If you compare category and tags, you'll immediately see that category has the additional parameters blank and null both set to True. So that's what we need to do for tags as well. Amend the model to look like this:

1class Post(models.Model):
2 title = models.CharField(max_length=200)
3 pub_date = models.DateTimeField()
4 text = models.TextField()
5 slug = models.SlugField(max_length=40, unique=True)
6 author = models.ForeignKey(User)
7 site = models.ForeignKey(Site)
8 category = models.ForeignKey(Category, blank=True, null=True)
9 tags = models.ManyToManyField(Tag, blank=True, null=True)

You shouldn't have to create a migration for this. Let's run our tests:

1$ python manage.py jenkins --coverage-html-report=htmlcov
2Creating test database for alias 'default'...
3.......................
4----------------------------------------------------------------------
5Ran 23 tests in 7.634s
6
7OK
8Destroying test database for alias 'default'...

Our tests pass! So we've fixed our bug, and we've ensured that if it happens again, we'll catch it. With that done, it's time to commit:

1$ git add blogengine/models.py
2$ git commit -m 'Fixed a bug with the Post model'

Remember: Always make the effort to create a test to reproduce your bug before fixing it. That way, you know you can catch it in future.

Syntax highlighting

This is one feature that not everyone will want to implement. If you want to be able to show code snippets on your blog, then implementing syntax highlighting is well worth your time. However, if that's not what you want to use your blog for, feel free to skip over this section.

Now, earlier in the series we implemented Markdown support. In Markdown there are two main ways to denote a code block. One is to indent the code by four spaces, while the other is to use fenced code blocks with syntax highlighting. There are many flavours of Markdown available for Python, and unfortunately the one we've been using so far doesn't support fenced code blocks, so we'll be switching to one that does.

We also need to be able to generate a suitable stylesheet to highlight the code appropriately. For that, we'll be using Pygments. So let's first uninstall our existing implementation of Markdown:

$ pip uninstall Markdown

And install the new modules:

$ pip install markdown2 Pygments

Don't forget to record the changes:

$ pip freeze > requirements.txt

Now, we need to amend our Markdown template tags to use the new version of Markdown:

1import markdown2
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 extras = ["fenced-code-blocks"]
14
15 return mark_safe(markdown2.markdown(force_unicode(value),
16 extras = extras))

All we do here is change the Markdown module that gets imported, and amend how it is called. Note that we pass through the parameter fenced-code-blocks to enable this functionality.

If you now run the development server and create a post with some code in it (just copy the Ruby example from here), then view it on the site, you should be able to see that it's in a <code> block. However, it's not highlighted yet. Let's commit our changes:

1$ git add requirements/txt blogengine/templatetags/custom_markdown.py
2$ git commit -m 'Now use Markdown2 to allow for syntax highlighting'

Now, if you examine the markup for your code blocks using your browser's developer tools, you'll notice that the code is wrapped in many different spans with various classes. Pygments can generate a CSS file that uses those classes to highlight your code.

First, let's create a folder to store our CSS files in:

$ mkdir blogengine/static/css

Next, we'll add a blank CSS file for any other styling we may want to apply:

$ touch blogengine/static/css/main.css

Then, we generate our CSS file:

$ pygmentize -S default -f html > blogengine/static/css/code.css

We need to include our CSS files in our HTML template:

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 <link rel="stylesheet" href="{% static 'css/main.css' %}">
21 <link rel="stylesheet" href="{% static 'css/code.css' %}">
22 <script src="{% static 'bower_components/html5-boilerplate/js/vendor/modernizr-2.6.2.min.js' %}"></script>
23 </head>
24 <body>
25 <!--[if lt IE 7]>
26 <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>
27 <![endif]-->
28
29 <!-- Add your site or application content here -->
30
31 <div id="fb-root"></div>
32 <script>(function(d, s, id) {
33 var js, fjs = d.getElementsByTagName(s)[0];
34 if (d.getElementById(id)) return;
35 js = d.createElement(s); js.id = id;
36 js.src = "//connect.facebook.net/en_GB/all.js#xfbml=1";
37 fjs.parentNode.insertBefore(js, fjs);
38 }(document, 'script', 'facebook-jssdk'));</script>
39
40 <div class="navbar navbar-static-top navbar-inverse">
41 <div class="navbar-inner">
42 <div class="container">
43 <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
44 <span class="icon-bar"></span>
45 <span class="icon-bar"></span>
46 <span class="icon-bar"></span>
47 </a>
48 <a class="brand" href="/">My Django Blog</a>
49 <div class="nav-collapse collapse">
50 </div>
51 </div>
52 </div>
53 </div>
54
55 <div class="container">
56 {% block header %}
57 <div class="page-header">
58 <h1>My Django Blog</h1>
59 </div>
60 {% endblock %}
61
62 <div class="row">
63 {% block content %}{% endblock %}
64 </div>
65 </div>
66
67 <div class="container footer">
68 <div class="row">
69 <div class="span12">
70 <p>Copyright &copy; {% now "Y" %}</p>
71 </div>
72 </div>
73 </div>
74
75 <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
76 <script>window.jQuery || document.write('<script src="{% static 'bower_components/html5-boilerplate/js/vendor/jquery-1.10.2.min.js' %}"><\/script>')</script>
77 <script src="{% static 'bower_components/html5-boilerplate/js/plugins.js' %}"></script>
78 <script src="{% static 'bower_components/bootstrap/dist/js/bootstrap.min.js' %}"></script>
79
80 <!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
81 <script>
82 (function(b,o,i,l,e,r){b.GoogleAnalyticsObject=l;b[l]||(b[l]=
83 function(){(b[l].q=b[l].q||[]).push(arguments)});b[l].l=+new Date;
84 e=o.createElement(i);r=o.getElementsByTagName(i)[0];
85 e.src='//www.google-analytics.com/analytics.js';
86 r.parentNode.insertBefore(e,r)}(window,document,'script','ga'));
87 ga('create','UA-XXXXX-X');ga('send','pageview');
88 </script>
89 </body>
90</html>

Now, if you run the development server and reload the page, your code will be highlighted using the default Pygments style. If you don't like it, there are plenty to choose from. Run the following command:

$ pygmentize -L styles

That will list the various styles available. For instance, let's say we want to try the Tango style:

$ pygmentize -S tango -f html > blogengine/static/css/code.css

If you like the Monokai theme in Sublime Text, there's a Pygments version of that:

$ pygmentize -S monokai -f html > blogengine/static/css/code.css

If you like the Solarized theme, that's not bundled with Pygments, but can be installed separately:

$ pip install pygments-style-solarized

Then run this for the light version:

$ pygmentize -S solarizedlight -f html > blogengine/static/css/code.css

And this for the dark version:

$ pygmentize -S solarizeddark -f html > blogengine/static/css/code.css

Pick one that you like - I'm going to go for the dark version of Solarized.

Note that this doesn't actually change the background colour of the code blocks. You will therefore need to set this manually using the CSS file we created earlier. If you're using Solarized Dark like I am, then this should set it correctly:

1div.codehilite pre {
2 background-color: #002b36;
3}

If you're using Solarized Light, then this should be more appropriate:

1div.codehilite pre {
2 background-color: #fdf6e3;
3}

Or, if you're using Monokai, black will do:

1div.codehilite pre {
2 background-color: #000000;
3}

With that done, let's record the additional Pygments style:

$ pip freeze > requirements.txt

And commit our changes:

1$ git add requirements.txt blogengine/static/css/ templates/blogengine/includes/base.html
2$ git commit -m 'Styled code with Solarized Dark'

Let's run our tests:

1$ python manage.py jenkins
2Creating test database for alias 'default'...
3E
4======================================================================
5ERROR: blogengine.tests (unittest.loader.ModuleImportFailure)
6----------------------------------------------------------------------
7ImportError: Failed to import test module: blogengine.tests
8Traceback (most recent call last):
9 File "/usr/local/Cellar/python/2.7.6_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/loader.py", line 254, in _find_tests
10 module = self._get_module_from_name(name)
11 File "/usr/local/Cellar/python/2.7.6_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/loader.py", line 232, in _get_module_from_name
12 __import__(name)
13 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 7, in <module>
14 import markdown
15ImportError: No module named markdown
16
17
18----------------------------------------------------------------------
19Ran 1 test in 0.001s
20
21FAILED (errors=1)
22Destroying test database for alias 'default'...

Whoops! We introduced an error here. If we take a look, we can see that the problem is on line 7, where we import the Markdown module. That makes sense, as we now use a different implementation of Markdown. Fortunately, in Python you can import modules with a different name, which makes this a breeze to fix. Change the line to:

import markdown2 as markdown

Now, if you run your tests, they should pass. It's important that if a test breaks, you fix it as soon as possible and don't put it off. Let's commit these changes:

1$ git add blogengine/tests.py
2$ git commit -m 'Fixed a broken test'

Our syntax highlighting is now done! If you want to see it in action using the Solarized Dark theme, check out the copy of this blogging engine hosted here.

Tidying up

Now that our blog is up and running on Heroku, it could do with a bit of work on the front end to make it look a bit nicer. If you recall, we're using Bootstrap for our front end, so you may want to refer to the documentation for that to give you some ideas on how you want to style your blog.

Bootstrap has a nifty pager class for Next and Previous links, so let's apply that to our post list template:

1<ul class="pager">
2 {% if page_obj.has_previous %}
3 <li class="previous"><a href="/{{ page_obj.previous_page_number }}/">Previous Page</a></li>
4 {% endif %}
5 {% if page_obj.has_next %}
6 <li class="next"><a href="/{{ page_obj.next_page_number }}/">Next Page</a></li>
7 {% endif %}
8 </ul>

Let's also add labels to our categories and tags. We'll also place our posts and other content inside proper columns:

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 {% endblock %}

We'll also want to tidy up the layout for our individual post pages:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 <div class="post col-md-12">
7 <h1>{{ object.title }}</h1>
8 <h3>{{ object.pub_date }}</h3>
9 {{ object.text|custom_markdown }}
10 </div>
11 {% if object.category %}
12 <div class="col-md-12">
13 <a href="{{ object.category.get_absolute_url }}"><span class="label label-primary">{{ object.category.name }}</span></a>
14 </div>
15 {% endif %}
16 {% if object.tags %}
17 <div class="col-md-12">
18 {% for tag in object.tags.all %}
19 <a href="{{ tag.get_absolute_url }}"><span class="label label-success">{{ tag.name }}</span></a>
20 {% endfor %}
21 </div>
22 {% endif %}
23
24 <div class="col-md-12">
25 <h4>Comments</h4>
26 <div class="fb-comments" data-href="http://{{ object.site }}{{ object.get_absolute_url }}" data-width="470" data-num-posts="10"></div>
27 </div>
28 </div>
29
30 {% endblock %}

Time to commit our changes:

1$ git add templates/blogengine/
2$ git commit -m 'Tidied up post templates'

With that done, let's turn our attention to our base template. We'll amend the header to collapse down at smaller screen widths. This is easy to do with Bootstrap:

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 </ul>
60 </div>
61 </div>
62 </div>
63
64 <div class="container">
65 {% block header %}
66 <div class="page-header">
67 <h1>My Django Blog</h1>
68 </div>
69 {% endblock %}
70
71 <div class="row">
72 {% block content %}{% endblock %}
73 </div>
74 </div>
75
76 <div class="container footer">
77 <div class="row">
78 <div class="span12">
79 <p>Copyright &copy; {% now "Y" %}</p>
80 </div>
81 </div>
82 </div>
83
84 <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
85 <script>window.jQuery || document.write('<script src="{% static 'bower_components/html5-boilerplate/js/vendor/jquery-1.10.2.min.js' %}"><\/script>')</script>
86 <script src="{% static 'bower_components/html5-boilerplate/js/plugins.js' %}"></script>
87 <script src="{% static 'bower_components/bootstrap/dist/js/bootstrap.min.js' %}"></script>
88
89 <!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
90 <script>
91 (function(b,o,i,l,e,r){b.GoogleAnalyticsObject=l;b[l]||(b[l]=
92 function(){(b[l].q=b[l].q||[]).push(arguments)});b[l].l=+new Date;
93 e=o.createElement(i);r=o.getElementsByTagName(i)[0];
94 e.src='//www.google-analytics.com/analytics.js';
95 r.parentNode.insertBefore(e,r)}(window,document,'script','ga'));
96 ga('create','UA-XXXXX-X');ga('send','pageview');
97 </script>
98 </body>
99</html>

Here we've also added a link to our RSS feed in the header, and another in the page head to facilitate working with browsers that support RSS better.

Our blog's now looking much more presentable. All that remains is to commit it:

1$ git add templates/blogengine/includes/base.html
2$ git commit -m 'Amended base template'

Now we can push it to GitHub and deploy it on Heroku:

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

And we're done! Don't forget, you can grab this lesson with git checkout lesson-6.

Next time, we'll cover search (I promise!).