Matthew Daly's Blog

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

19th October 2014 7:52 pm

My Django Web Server Setup

This isn’t really part of my Django tutorial series (that has now definitely concluded!), but I thought I’d share the setup I generally use for deploying Django applications, partly for my own reference, and partly because it is quite complex, and those readers who don’t wish to deploy to Heroku may want some guidance on how to deploy their new blogs to a VPS.

Operating system

This isn’t actually that much of a big deal, but while I prefer Ubuntu on desktops, I generally use Debian Stable on servers, since it’s fanatically stable.

Database server

For my first commercial Django app, I used MySQL. However, South had one or two issues with MySQL, and I figured that since using an ORM and migrations meant that I wouldn’t need to write much SQL anyway, I might as well jump to PostgreSQL for the Django app I’m currently in the process of deploying at work. So far I haven’t had any problems with it.

Web server

It’s customary to use two web servers with Django. One handles the static content, and reverse proxies everything else to a different port, where another web server serves the dynamic content.

For serving the static files, I use Nginx - it’s generally considered to be faster than Apache for this use case. Here’s a typical Nginx config file:

server {
listen 80;
server_name example.com;
client_max_body_size 50M;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
location /static {
root /var/www/mysite;
}
location /media {
root /var/www/mysite;
}
location / {
proxy_pass http://127.0.0.1:8000;
}
}

For the application server, I use Gunicorn. You can install this with pip install gunicorn, then add it to INSTALLED_APPS. Then add the following config file to the root of your project, as gunicorn.conf.py:

bind = "127.0.0.1:8000"
logfile = "/var/log/gunicorn.log"
loglevel = "debug"
workers = 3

You should normally set the number of workers to 2 times the number of cores on your machine, plus one.

In order to keep Gunicorn running, I use Supervisor. As the installation commands will depend on your OS, I won’t give details here - your package manager of choice should have a suitable package available. Here’s a typical Supervisor config file I might use for running Gunicorn for a Django app, named mysite-supervisor.conf:

[program:mysite]
command=/var/www/mysite/venv/bin/gunicorn myapp.wsgi:application --workers=3
directory=/var/www/mysite/
user=nobody
autostart=true
autorestart=true

Once that’s in place, you can easily add the new app:

$ sudo supervisorctl reread
$ sudo supervisorctl update

Then to start it:

$ sudo supervisorctl start mysite

Or stop it with:

$ sudo supervisorctl stop mysite

Or restart it:

$ sudo supervisorctl restart mysite

Celery

So far, both of the web apps I’ve built professionally have been ones where it made sense to use Celery for some tasks. For the uninitiated, Celery lets you pass a task to a queue to be handled, rather than handling it within the context of the same HTTP request. This offers the following advantages:

  • The user doesn’t need to wait for the task to be completed before getting a response, improving performance
  • It’s more robust, since if the task fails, it can be automatically retried
  • The task queue can be moved to another server if desired, making it easier to scale
  • Scheduling tasks

I’ve used it in cases where I needed to send an email or a push notification, since these don’t have to be done within the context of the same HTTP request, but need to be reliable.

I generally use RabbitMQ as my message queue. I’ll leave setting this up as an exercise for the reader since it’s covered pretty well in the Celery documentation, but like with Gunicorn, I use Supervisor to run the Celery worker. Here’s a typical config file, which might be called celery-supervisor.conf:

[program:celeryd]
command=/var/www/mysite/venv/bin/python manage.py celery worker
directory=/var/www/mysite/
user=nobody
autostart=true
autorestart=true

Then start it up:

$ sudo supervisorctl reread
$ sudo supervisorctl update
$ sudo supervisorctl start celeryd

I make no claims about how good this setup is, but it works well for me. I haven’t yet had the occasion to deploy a Django app to anywhere other than Heroku that really benefited from caching, so I haven’t got any tips to share about that, but if I were building a content-driven web app, I would use Memcached since it’s well-supported.

5th October 2014 7:56 pm

Introducing Generator-simple-static-blog

I’m a big fan of static site generators. I ditched WordPress for Octopress over two years ago because it was free to host on GitHub Pages and much faster, had much better syntax highlighting, and I liked being able to write posts in Vim, and I’ve never looked back since.

That said, Octopress is written in Ruby, a language I’ve never been that keen on. Ideally I’d prefer to use Python or JavaScript, but none of the solutions I’ve found have been to my liking. Recently I’ve been using Grunt and Yeoman to some extent, and I’ve wondered about the idea of creating a Yeoman generator to build a static blogging engine. After discovering grunt-markdown-blog, I took the plunge and have built a simple blog generator called generator-simple-static-blog.

I’ve published it to NPM, so feel free to check it out. It includes code highlighting with the Zenburn colour scheme by default (although highlight.js includes many other themes, so just switch to another one if you want), and it should be easy to edit the templates. I’ve also included the ability to deploy automatically to GitHub Pages using Grunt.

I don’t anticipate moving over to this from Octopress for the foreseeable future, but it’s been an interesting project for the weekend.

28th September 2014 8:51 pm

Django Blog Tutorial - the Next Generation - Part 9

Yes, I know the eight instalment was meant to be the last one! Within 24 hours of that post going live, Django 1.7 was released, so naturally I’d like to show you how to upgrade to it.

The biggest change is that Django 1.7 introduces its own migration system, which means South is now surplus to requirements. We therefore need to switch from South to Django’s native migrations. Fortunately, this is fairly straightforward.

First of all, activate your virtualenv:

$ virtualenv venv

Then make sure your migrations are up to date:

$ python manage.py syncdb
$ python manage.py migrate

Then, upgrade your Django version and uninstall South:

$ pip install Django --upgrade
$ pip uninstall South
$ pip freeze > requirements.txt

Next, remove South from INSTALLED_APPS in django_tutorial_blog_ng/settings.py.

You now need to delete all of the numbered migration files in blogengine/migrations/, and the relevant .pyc files, but NOT the directory or the __init__.py file. You can do so with this command on Linux or OS X:

$ rm blogengine/migrations/00*

Next, we recreate our migrations with the following command:

$ python manage.py makemigrations
Migrations for 'blogengine':
0001_initial.py:
- Create model Category
- Create model Post
- Create model Tag
- Add field tags to post

Then we run the migrations:

$ python manage.py migrate
Operations to perform:
Synchronize unmigrated apps: sitemaps, django_jenkins, debug_toolbar
Apply all migrations: sessions, admin, sites, flatpages, contenttypes, auth, blogengine
Synchronizing apps without migrations:
Creating tables...
Installing custom SQL...
Installing indexes...
Running migrations:
Applying contenttypes.0001_initial... FAKED
Applying auth.0001_initial... FAKED
Applying admin.0001_initial... FAKED
Applying sites.0001_initial... FAKED
Applying blogengine.0001_initial... FAKED
Applying flatpages.0001_initial... FAKED
Applying sessions.0001_initial... FAKED

Don’t worry too much if the output doesn’t look exactly the same as this - as long as it works, that’s the main thing.

Let’s run our test suite to ensure it works:

$ python manage.py jenkins
Creating test database for alias 'default'...
....FF.F.FFFFFF..............
======================================================================
FAIL: test_create_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 385, in test_create_post
self.assertTrue('added successfully' in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_create_post_without_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 417, in test_create_post_without_tag
self.assertTrue('added successfully' in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_delete_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 278, in test_delete_category
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_delete_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 346, in test_delete_tag
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_edit_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 255, in test_edit_category
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_edit_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 447, in test_edit_post
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_edit_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 323, in test_edit_tag
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_login (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 183, in test_login
self.assertEquals(response.status_code, 200)
AssertionError: 302 != 200
======================================================================
FAIL: test_logout (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 214, in test_logout
self.assertEquals(response.status_code, 200)
AssertionError: 302 != 200
----------------------------------------------------------------------
Ran 29 tests in 7.383s
FAILED (failures=9)
Destroying test database for alias 'default'...

We have an issue here. A load of the tests for the admin interface now fail. If we now try running the dev server, we see this error:

$ python manage.py runserver
Performing system checks...
System check identified no issues (0 silenced).
September 28, 2014 - 20:16:47
Django version 1.7, using settings 'django_tutorial_blog_ng.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
Unhandled exception in thread started by <function wrapper at 0x1024a5ed8>
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/utils/autoreload.py", line 222, in wrapper
fn(*args, **kwargs)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/core/management/commands/runserver.py", line 132, in inner_run
handler = self.get_handler(*args, **options)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/contrib/staticfiles/management/commands/runserver.py", line 25, in get_handler
handler = super(Command, self).get_handler(*args, **options)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/core/management/commands/runserver.py", line 48, in get_handler
return get_internal_wsgi_application()
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/core/servers/basehttp.py", line 66, in get_internal_wsgi_application
sys.exc_info()[2])
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/core/servers/basehttp.py", line 56, in get_internal_wsgi_application
return import_string(app_path)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/utils/module_loading.py", line 26, in import_string
module = import_module(module_path)
File "/usr/local/Cellar/python/2.7.8_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/importlib/__init__.py", line 37, in import_module
__import__(name)
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/django_tutorial_blog_ng/wsgi.py", line 14, in <module>
from dj_static import Cling
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/dj_static.py", line 7, in <module>
from django.core.handlers.base import get_path_info
django.core.exceptions.ImproperlyConfigured: WSGI application 'django_tutorial_blog_ng.wsgi.application' could not be loaded; Error importing module: 'cannot import name get_path_info'

Fortunately, the error above is easy to fix by upgrading dj_static:

$ pip install dj_static --upgrade
$ pip freeze > requirements.txt

That resolves the error in serving static files, but not the error with the admin. If you run the dev server, you’ll be able to see that the admin actually works fine. The problem is caused by the test client not following redirects in the admin. We can easily run just the admin tests with the following command:

$ python manage.py test blogengine.tests.AdminTest
Creating test database for alias 'default'...
.FF.F.FFFFFF
======================================================================
FAIL: test_create_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 385, in test_create_post
self.assertTrue('added successfully' in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_create_post_without_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 417, in test_create_post_without_tag
self.assertTrue('added successfully' in response.content)
AssertionError: False is not true
======================================================================
FAIL: test_delete_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 278, in test_delete_category
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_delete_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 346, in test_delete_tag
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_edit_category (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 255, in test_edit_category
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_edit_post (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 447, in test_edit_post
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_edit_tag (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 323, in test_edit_tag
self.assertEquals(response.status_code, 200)
AssertionError: 404 != 200
======================================================================
FAIL: test_login (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 183, in test_login
self.assertEquals(response.status_code, 200)
AssertionError: 302 != 200
======================================================================
FAIL: test_logout (blogengine.tests.AdminTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 214, in test_logout
self.assertEquals(response.status_code, 200)
AssertionError: 302 != 200
----------------------------------------------------------------------
Ran 12 tests in 3.283s
FAILED (failures=9)
Destroying test database for alias 'default'...

Let’s commit our changes so far first:

$ git add django_tutorial_blog_ng/ requirements.txt blogengine/
$ git commit -m 'Upgraded to Django 1.7'

Now let’s fix our tests. Here’s the amended version of the AdminTest class:

class AdminTest(BaseAcceptanceTest):
fixtures = ['users.json']
def test_login(self):
# Get login page
response = self.client.get('/admin/', follow=True)
# Check response code
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
# Log the user in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
def test_logout(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/')
self.assertEquals(response.status_code, 200)
# Check 'Log out' in response
self.assertTrue('Log out' in response.content)
# Log out
self.client.logout()
# Check response code
response = self.client.get('/admin/', follow=True)
self.assertEquals(response.status_code, 200)
# Check 'Log in' in response
self.assertTrue('Log in' in response.content)
def test_create_category(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/category/add/')
self.assertEquals(response.status_code, 200)
# Create the new category
response = self.client.post('/admin/blogengine/category/add/', {
'name': 'python',
'description': 'The Python programming language'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new category now in database
all_categories = Category.objects.all()
self.assertEquals(len(all_categories), 1)
def test_edit_category(self):
# Create the category
category = CategoryFactory()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the category
response = self.client.post('/admin/blogengine/category/' + str(category.pk) + '/', {
'name': 'perl',
'description': 'The Perl programming language'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check category amended
all_categories = Category.objects.all()
self.assertEquals(len(all_categories), 1)
only_category = all_categories[0]
self.assertEquals(only_category.name, 'perl')
self.assertEquals(only_category.description, 'The Perl programming language')
def test_delete_category(self):
# Create the category
category = CategoryFactory()
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the category
response = self.client.post('/admin/blogengine/category/' + str(category.pk) + '/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check category deleted
all_categories = Category.objects.all()
self.assertEquals(len(all_categories), 0)
def test_create_tag(self):
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/tag/add/')
self.assertEquals(response.status_code, 200)
# Create the new tag
response = self.client.post('/admin/blogengine/tag/add/', {
'name': 'python',
'description': 'The Python programming language'
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new tag now in database
all_tags = Tag.objects.all()
self.assertEquals(len(all_tags), 1)
def test_edit_tag(self):
# Create the tag
tag = TagFactory()
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the tag
response = self.client.post('/admin/blogengine/tag/' + str(tag.pk) + '/', {
'name': 'perl',
'description': 'The Perl programming language'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check tag amended
all_tags = Tag.objects.all()
self.assertEquals(len(all_tags), 1)
only_tag = all_tags[0]
self.assertEquals(only_tag.name, 'perl')
self.assertEquals(only_tag.description, 'The Perl programming language')
def test_delete_tag(self):
# Create the tag
tag = TagFactory()
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the tag
response = self.client.post('/admin/blogengine/tag/' + str(tag.pk) + '/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check tag deleted
all_tags = Tag.objects.all()
self.assertEquals(len(all_tags), 0)
def test_create_post(self):
# Create the category
category = CategoryFactory()
# Create the tag
tag = TagFactory()
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)
# Create the new post
response = self.client.post('/admin/blogengine/post/add/', {
'title': 'My first post',
'text': 'This is my first post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-first-post',
'site': '1',
'category': str(category.pk),
'tags': str(tag.pk)
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new post now in database
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
def test_create_post_without_tag(self):
# Create the category
category = CategoryFactory()
# Log in
self.client.login(username='bobsmith', password="password")
# Check response code
response = self.client.get('/admin/blogengine/post/add/')
self.assertEquals(response.status_code, 200)
# Create the new post
response = self.client.post('/admin/blogengine/post/add/', {
'title': 'My first post',
'text': 'This is my first post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-first-post',
'site': '1',
'category': str(category.pk)
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check added successfully
self.assertTrue('added successfully' in response.content)
# Check new post now in database
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
def test_edit_post(self):
# Create the post
post = PostFactory()
# Create the category
category = CategoryFactory()
# Create the tag
tag = TagFactory()
post.tags.add(tag)
# Log in
self.client.login(username='bobsmith', password="password")
# Edit the post
response = self.client.post('/admin/blogengine/post/' + str(post.pk) + '/', {
'title': 'My second post',
'text': 'This is my second blog post',
'pub_date_0': '2013-12-28',
'pub_date_1': '22:00:04',
'slug': 'my-second-post',
'site': '1',
'category': str(category.pk),
'tags': str(tag.pk)
},
follow=True
)
self.assertEquals(response.status_code, 200)
# Check changed successfully
self.assertTrue('changed successfully' in response.content)
# Check post amended
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
only_post = all_posts[0]
self.assertEquals(only_post.title, 'My second post')
self.assertEquals(only_post.text, 'This is my second blog post')
def test_delete_post(self):
# Create the post
post = PostFactory()
# Create the tag
tag = TagFactory()
post.tags.add(tag)
# Check new post saved
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 1)
# Log in
self.client.login(username='bobsmith', password="password")
# Delete the post
response = self.client.post('/admin/blogengine/post/' + str(post.pk) + '/delete/', {
'post': 'yes'
}, follow=True)
self.assertEquals(response.status_code, 200)
# Check deleted successfully
self.assertTrue('deleted successfully' in response.content)
# Check post deleted
all_posts = Post.objects.all()
self.assertEquals(len(all_posts), 0)

There are two main issues here. The first is that when we try to edit or delete an existing item, or refer to it when creating something else, we can no longer rely on the number representing the primary key being set to 1. So we need to specifically obtain this, rather than hard-coding it to 1. Therefore, whenever we pass through a number to represent an item (with the exception of the site, but including tags, categories and posts), we need to instead fetch its primary key and return it. So, above where we try to delete a post, we replace 1 with str(post.pk). This will solve a lot of the problems. As there’s a lot of them, I won’t go through each one, but you can see the entire class above for reference, and if you’ve followed along so far, you shouldn’t have any problems.

The other issue we need to fix is the login and logout tests. We simply add follow=True to these to ensure that the test client follows the redirects.

Let’s run our tests to make sure they pass:

$ python manage.py jenkins
Creating test database for alias 'default'...
.............................
----------------------------------------------------------------------
Ran 29 tests in 8.210s
OK
Destroying test database for alias 'default'...

With that done, you can commit your changes:

$ git add blogengine/tests.py
$ git commit -m 'Fixed broken tests'

Don’t forget to deploy your changes:

$ fab deploy

Our blog has now been happily migrated over to Django 1.7!

28th September 2014 7:53 pm

Changing Date Format from DD/MM/YYYY to YYYY-MM-DD in Vim

Recently I had the occasion to reformat a load of dates in Vim from DD/MM/YYYY to YYYY-MM-DD. In Vim, this is quite simple:

:%s/\(\d\{2}\)\/\(\d\{2}\)\/\(\d\{4}\)/\3-\2-\1/g

This should be easy to adapt to reformatting other date formats.

31st August 2014 10:00 pm

Django Blog Tutorial - the Next Generation - Part 8

Hello again! In our final instalment, we’ll wrap up our blog by:

  • Implementing a sitemap
  • Optimising and tidying up the site
  • Creating a Fabric task for easier deployment

I’ll also cover development tools and practices that can make using Django easier. But first there’s a few housekeeping tasks that need doing…

Don’t forget to activate your virtualenv - you should know how to do this off by heart by now!

Upgrading Django

At the time of writing, Django 1.7 is due any day now, but it’s not out yet so I won’t cover it. The biggest change is the addition of a built-in migration system, but switching from South to this is well-documented. When Django 1.7 comes out, it shouldn’t be difficult to upgrade to it - because we have good test coverage, we shouldn’t have much trouble catching errors.

However, Django 1.6.6 was recently released, and we need to upgrade to it. Just enter the following command to upgrade:

$ pip install Django --upgrade

Then add it to your requirements.txt:

$ pip freeze > requirements.txt

Then commit your changes:

$ git add requirements.txt
$ git commit -m 'Upgraded Django version'

Implementing a sitemap

Creating a sitemap for your blog is a good idea - it can be submitted to search engines, so that they can easily find your content. With Django, it’s pretty straightforward too.

First, let’s create a test for our sitemap. Add the following code at the end of tests.py:

class SitemapTest(BaseAcceptanceTest):
def test_sitemap(self):
# Create a post
post = PostFactory()
# Create a flat page
page = FlatPageFactory()
# Get sitemap
response = self.client.get('/sitemap.xml')
self.assertEquals(response.status_code, 200)
# Check post is present in sitemap
self.assertTrue('my-first-post' in response.content)
# Check page is present in sitemap
self.assertTrue('/about/' in response.content)

Run it, and you should see the test fail:

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

Now, let’s implement our sitemap. The sitemap application comes with Django, and needs to be activated in your settings file, under INSTALLED_APPS:

    'django.contrib.sitemaps',

Next, let’s think about what content we want to include in the sitemap. We want to index our flat pages and our blog posts, so our sitemap should reflect that. Create a new file at blogengine/sitemap.py and enter the following text:

from django.contrib.sitemaps import Sitemap
from django.contrib.flatpages.models import FlatPage
from blogengine.models import Post
class PostSitemap(Sitemap):
changefreq = "always"
priority = 0.5
def items(self):
return Post.objects.all()
def lastmod(self, obj):
return obj.pub_date
class FlatpageSitemap(Sitemap):
changefreq = "always"
priority = 0.5
def items(self):
return FlatPage.objects.all()

We define two sitemaps, one for all the posts, and the other for all the flat pages. Note that this works in a very similar way to the syndication framework.

Next, we amend our URLs. Add the following text after the existing imports in your URL file:

from django.contrib.sitemaps.views import sitemap
from blogengine.sitemap import PostSitemap, FlatpageSitemap
# Define sitemaps
sitemaps = {
'posts': PostSitemap,
'pages': FlatpageSitemap
}

Then add the following after the existing routes:

# Sitemap
url(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'),

Here we define what sitemaps we’re going to use, and we define a URL for them. It’s pretty straightforward to use.

Let’s run our tests:

$ python manage.py test blogengine
Creating test database for alias 'default'...
............................
----------------------------------------------------------------------
Ran 28 tests in 6.863s
OK
Destroying test database for alias 'default'...

And done! Let’s commit our changes:

$ git add blogengine/ django_tutorial_blog_ng/settings.py
$ git commit -m 'Implemented a sitemap'

Fixing test coverage

Our blog is now feature-complete, but there are a few gaps in test coverage, so we’ll fix them. If, like me, you’re using Coveralls.io, you can easily see via their web interface where there are gaps in the coverage.

Now, our gaps are all in our view file - if you take a look at my build, you can easily identify the gaps as they’re marked in red.

The first gap is where a tag does not exist. Interestingly, if we look at the code in the view, we can see that some of it is redundant:

class TagPostsFeed(PostsFeed):
def get_object(self, request, slug):
return get_object_or_404(Tag, slug=slug)
def title(self, obj):
return "RSS feed - blog posts tagged %s" % obj.name
def link(self, obj):
return obj.get_absolute_url()
def description(self, obj):
return "RSS feed - blog posts tagged %s" % obj.name
def items(self, obj):
try:
tag = Tag.objects.get(slug=obj.slug)
return tag.post_set.all()
except Tag.DoesNotExist:
return Post.objects.none()

Under the items function, we check to see if the tag exists. However, under get_object we can see that if the object didn’t exist, it would already have returned a 404 error. We can therefore safely amend items to not check, since that try statement will never fail:

class TagPostsFeed(PostsFeed):
def get_object(self, request, slug):
return get_object_or_404(Tag, slug=slug)
def title(self, obj):
return "RSS feed - blog posts tagged %s" % obj.name
def link(self, obj):
return obj.get_absolute_url()
def description(self, obj):
return "RSS feed - blog posts tagged %s" % obj.name
def items(self, obj):
tag = Tag.objects.get(slug=obj.slug)
return tag.post_set.all()

The other two gaps are in our search view - we never get an empty result for the search in the following section:

def getSearchResults(request):
"""
Search for a post by title or text
"""
# Get the query data
query = request.GET.get('q', '')
page = request.GET.get('page', 1)
# Query the database
if query:
results = Post.objects.filter(Q(text__icontains=query) | Q(title__icontains=query))
else:
results = None
# Add pagination
pages = Paginator(results, 5)
# Get specified page
try:
returned_page = pages.page(page)
except EmptyPage:
returned_page = pages.page(pages.num_pages)
# Display the search results
return render_to_response('blogengine/search_post_list.html',
{'page_obj': returned_page,
'object_list': returned_page.object_list,
'search': query})

So replace it with this:

def getSearchResults(request):
"""
Search for a post by title or text
"""
# Get the query data
query = request.GET.get('q', '')
page = request.GET.get('page', 1)
# Query the database
results = Post.objects.filter(Q(text__icontains=query) | Q(title__icontains=query))
# Add pagination
pages = Paginator(results, 5)
# Get specified page
try:
returned_page = pages.page(page)
except EmptyPage:
returned_page = pages.page(pages.num_pages)
# Display the search results
return render_to_response('blogengine/search_post_list.html',
{'page_obj': returned_page,
'object_list': returned_page.object_list,
'search': query})

We don’t need to check whether query is defined because if q is left blank, the value of query will be an empty string, so we may as well pull out the redundant code.

Finally, the other gap is for when a user tries to get an empty search page (eg, page two of something with five or less results). So let’s add another test to our SearchViewTest class:

def test_failing_search(self):
# Search for something that is not present
response = self.client.get('/search?q=wibble')
self.assertEquals(response.status_code, 200)
self.assertTrue('No posts found' in response.content)
# Try to get nonexistent second page
response = self.client.get('/search?q=wibble&page=2')
self.assertEquals(response.status_code, 200)
self.assertTrue('No posts found' in response.content)

Run our tests and check the coverage:

$ coverage run --include="blogengine/*" --omit="blogengine/migrations/*" manage.py test blogengine
$ coverage html

If you open htmlcov/index.html in your browser, you should see that the test coverage is back up to 100%. With that done, it’s time to commit again:

$ git add blogengine/
$ git commit -m 'Fixed gaps in coverage'

Remember, it’s not always possible to achieve 100% test coverage, and you shouldn’t worry too much about it if it’s not possible - it’s possible to ignore code if necessary. However, it’s a good idea to aim for 100%.

Using Fabric for deployment

Next we’ll cover using Fabric, a handy tool for deploying your changes (any pretty much any other task you want to automate). First, you need to install it:

$ pip install Fabric

If you have any problems installing it, you should be able to resolve them via Google - most of them are likely to be absent libraries that Fabric depends upon. Once it’s installed, add it to your requirements.tzt:

$ pip freeze > requirements.txt

Next, create a file called fabfile.py and enter the following text:

#!/usr/bin/env python
from fabric.api import local
def deploy():
"""
Deploy the latest version to Heroku
"""
# Push changes to master
local("git push origin master")
# Push changes to Heroku
local("git push heroku master")
# Run migrations on Heroku
local("heroku run python manage.py migrate")

Now, all this file does is push our changes to Github (or wherever else your repository is hosted) and to Heroku, and runs your migrations. It’s not a terribly big task anyway, but it’s handy to have it in place. Let’s commit our changes:

$ git add fabfile.py requirements.txt
$ git commit -m 'Added Fabric task for deployment'

Then, let’s try it out:

$ fab deploy

There, wasn’t that more convenient? Fabric is much more powerful than this simple demonstration indicates, and can run tasks on remote servers via SSH easily. I recommend you take a look at the documentation to see what else you can do with it. If you’re hosting your site on a VPS, you will probably find Fabric indispensable, as you will need to restart the application every time you push up a new revision.

Tidying up

We want our blog application to play nicely with other Django apps. For instance, say you’re working on a new site that includes a blogging engine. Wouldn’t it make sense to just be able to drop in this blogging engine and have it work immediately? At the moment, some of our URL’s are hard-coded, so we may have problems in doing so. Let’s fix that.

First we’ll amend our tests. Add this at the top of the tests file:

from django.core.urlresolvers import reverse

Next, replace every instance of this:

        response = self.client.get('/')

with this:

response = self.client.get(reverse('blogengine:index'))

Then, rewrite the calls to the search route. For instance, this:

        response = self.client.get('/search?q=first')

should become this:

        response = self.client.get(reverse('blogengine:search') + '?q=first')

I’ll leave changing these as an exercise for the reader, but check the repository if you get stuck.

Next, we need to assign a namespace to our app’s routes:

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

We then assign names to our routes in the app’s urls.py:

from django.conf.urls import patterns, url
from django.views.generic import ListView, DetailView
from blogengine.models import Post, Category, Tag
from blogengine.views import CategoryListView, TagListView, PostsFeed, CategoryPostsFeed, TagPostsFeed, getSearchResults
from django.contrib.sitemaps.views import sitemap
from blogengine.sitemap import PostSitemap, FlatpageSitemap
# Define sitemaps
sitemaps = {
'posts': PostSitemap,
'pages': FlatpageSitemap
}
urlpatterns = patterns('',
# Index
url(r'^(?P<page>\d+)?/?$', ListView.as_view(
model=Post,
paginate_by=5,
),
name='index'
),
# Individual posts
url(r'^(?P<pub_date__year>\d{4})/(?P<pub_date__month>\d{1,2})/(?P<slug>[a-zA-Z0-9-]+)/?$', DetailView.as_view(
model=Post,
),
name='post'
),
# Categories
url(r'^category/(?P<slug>[a-zA-Z0-9-]+)/?$', CategoryListView.as_view(
paginate_by=5,
model=Category,
),
name='category'
),
# Tags
url(r'^tag/(?P<slug>[a-zA-Z0-9-]+)/?$', TagListView.as_view(
paginate_by=5,
model=Tag,
),
name='tag'
),
# Post RSS feed
url(r'^feeds/posts/$', PostsFeed()),
# Category RSS feed
url(r'^feeds/posts/category/(?P<slug>[a-zA-Z0-9-]+)/?$', CategoryPostsFeed()),
# Tag RSS feed
url(r'^feeds/posts/tag/(?P<slug>[a-zA-Z0-9-]+)/?$', TagPostsFeed()),
# Search posts
url(r'^search', getSearchResults, name='search'),
# Sitemap
url(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'),
)

You also need to amend two of your templates:

<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{% block title %}My Django Blog{% endblock %}</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="alternate" type="application/rss+xml" title="Blog posts" href="/feeds/posts/" >
<!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
{% load staticfiles %}
<link rel="stylesheet" href="{% static 'bower_components/html5-boilerplate/css/normalize.css' %}">
<link rel="stylesheet" href="{% static 'bower_components/html5-boilerplate/css/main.css' %}">
<link rel="stylesheet" href="{% static 'bower_components/bootstrap/dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'bower_components/bootstrap/dist/css/bootstrap-theme.min.css' %}">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<link rel="stylesheet" href="{% static 'css/code.css' %}">
<script src="{% static 'bower_components/html5-boilerplate/js/vendor/modernizr-2.6.2.min.js' %}"></script>
</head>
<body>
<!--[if lt IE 7]>
<p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
<![endif]-->
<!-- Add your site or application content here -->
<div id="fb-root"></div>
<script>(function(d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
js = d.createElement(s); js.id = id;
js.src = "//connect.facebook.net/en_GB/all.js#xfbml=1";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>
<div class="navbar navbar-static-top navbar-inverse">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#header-nav">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{% url 'blogengine:index' %}">My Django Blog</a>
</div>
<div class="collapse navbar-collapse" id="header-nav">
<ul class="nav navbar-nav">
{% load flatpages %}
{% get_flatpages as flatpages %}
{% for flatpage in flatpages %}
<li><a href="{{ flatpage.url }}">{{ flatpage.title }}</a></li>
{% endfor %}
<li><a href="/feeds/posts/">RSS feed</a></li>
<form action="/search" method="GET" class="navbar-form navbar-left">
<div class="form-group">
<input type="text" name="q" placeholder="Search..." class="form-control"></input>
</div>
<button type="submit" class="btn btn-default">Search</button>
</form>
</ul>
</div>
</div>
</div>
<div class="container">
{% block header %}
<div class="page-header">
<h1>My Django Blog</h1>
</div>
{% endblock %}
<div class="row">
{% block content %}{% endblock %}
</div>
</div>
<div class="container footer">
<div class="row">
<div class="span12">
<p>Copyright &copy; {% now "Y" %}</p>
</div>
</div>
</div>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
<script>window.jQuery || document.write('<script src="{% static 'bower_components/html5-boilerplate/js/vendor/jquery-1.10.2.min.js' %}"><\/script>')</script>
<script src="{% static 'bower_components/html5-boilerplate/js/plugins.js' %}"></script>
<script src="{% static 'bower_components/bootstrap/dist/js/bootstrap.min.js' %}"></script>
<!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
<script>
(function(b,o,i,l,e,r){b.GoogleAnalyticsObject=l;b[l]||(b[l]=
function(){(b[l].q=b[l].q||[]).push(arguments)});b[l].l=+new Date;
e=o.createElement(i);r=o.getElementsByTagName(i)[0];
e.src='//www.google-analytics.com/analytics.js';
r.parentNode.insertBefore(e,r)}(window,document,'script','ga'));
ga('create','UA-XXXXX-X');ga('send','pageview');
</script>
</body>
</html>
{% extends "blogengine/includes/base.html" %}
{% load custom_markdown %}
{% block content %}
{% if object_list %}
{% for post in object_list %}
<div class="post col-md-12">
<h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
<h3>{{ post.pub_date }}</h3>
{{ post.text|custom_markdown }}
</div>
{% if post.category %}
<div class="col-md-12">
<a href="{{ post.category.get_absolute_url }}"><span class="label label-primary">{{ post.category.name }}</span></a>
</div>
{% endif %}
{% if post.tags %}
<div class="col-md-12">
{% for tag in post.tags.all %}
<a href="{{ tag.get_absolute_url }}"><span class="label label-success">{{ tag.name }}</span></a>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% else %}
<p>No posts found</p>
{% endif %}
<ul class="pager">
{% if page_obj.has_previous %}
<li class="previous"><a href="{% url 'blogengine:search' %}?page={{ page_obj.previous_page_number }}&q={{ search }}">Previous Page</a></li>
{% endif %}
{% if page_obj.has_next %}
<li class="next"><a href="{% url 'blogengine:search' %}?page={{ page_obj.next_page_number }}&q={{ search }}">Next Page</a></li>
{% endif %}
</ul>
{% endblock %}

Let’s run our tests:

$ python manage.py test blogengine/
Creating test database for alias 'default'...
.............................
----------------------------------------------------------------------
Ran 29 tests in 10.456s
OK
Destroying test database for alias 'default'...

And commit our changes:

$ git add .
$ git commit -m 'Now use named routes'

Debugging Django

There are a number of handy ways to debug Django applications. One of the simplest is to use the Python debugger. To use it, just enter the following lines at the point you want to break at:

import pdb
pdb.set_trace()

Now, whenever that line of code is run, you’ll be dropped into an interactive shell that lets you play around to find out what’s going wrong. However, it doesn’t offer autocompletion, so we’ll install ipdb, which is an improved version:

$ pip install ipdb
$ pip freeze > requirements.txt

Now you can use ipdb in much the same way as you would use pdb:

import ipdb
ipdb.set_trace()

Now, ipdb is very useful, but it isn’t much help for profiling your application. For that you need the Django Debug Toolbar. Run the following commands:

$ pip install django-debug-toolbar
$ pip freeze > requirements.txt

Then add the following line to INSTALLED_APPS in your settings file:

    'debug_toolbar',

Then, try running the development server, and you’ll see a toolbar on the right-hand side of the screen that allows you to view some useful data about your page. For instance, you’ll notice a field called SQL - this contains details of the queries carried out when building the page. To actually see the queries carried out, you’ll want to disable caching in your settings file by commenting out all the constants that start with CACHE.

We won’t go into using the toolbar to optimise queries, but using this, you can easily see what queries are being executed on a specific page, how long they take, and the values they return. Sometimes, you may need to optimise a slow query - in this case, Django allows you to drop down to writing raw SQL if necessary.

Note that if you’re running Django in production, you should set DEBUG to False as otherwise it gives rather too much information to potential attackers, and with Django Debug Toolbar installed, that’s even more important.

Please also note that when you disable debug mode, Django no longer handles static files automatically, so you’ll need to run python manage.py collectstatic and commit the staticfiles directory.

Once you’ve disabled debug mode, collected the static files, and re-enables caching, you can commit your changes:

$ git add .
$ git commit -m 'Installed debugging tools'

Optimising static files

We want our blog to get the best SEO results it can, so making it fast is essential. One of the simplest things you can do is to concatenate and minify static assets such as CSS and JavaScript. There are numerous ways to do this, but I generally use Grunt. Let’s set up a Grunt config to concatenate and minify our CSS and JavaScript.

You’ll need to have Node.js installed on your development machine for this. Then, you need to install the Grunt command-line interface:

$ sudo npm install -g grunt-cli

With that done, we need to create a package.json file. You can create one using the command npm init. Here’s mine:

{
"name": "django_tutorial_blog_ng",
"version": "1.0.0",
"description": "Django Tutorial Blog NG =======================",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://github.com/matthewbdaly/django_tutorial_blog_ng.git"
},
"author": "Matthew Daly <matthew@matthewdaly.co.uk> (http://matthewdaly.co.uk/)",
"license": "ISC",
"bugs": {
"url": "https://github.com/matthewbdaly/django_tutorial_blog_ng/issues"
},
"homepage": "https://github.com/matthewbdaly/django_tutorial_blog_ng"
}

Feel free to amend it as you see fit.

Next we install Grunt and the required plugins:

$ npm install grunt grunt-contrib-cssmin grunt-contrib-concat grunt-contrib-uglify --save-dev

We now need to create a Gruntfile for our tasks:

module.exports = function (grunt) {
'use strict';
grunt.initConfig({
concat: {
dist: {
src: [
'blogengine/static/bower_components/bootstrap/dist/css/bootstrap.css',
'blogengine/static/bower_components/bootstrap/dist/css/bootstrap-theme.css',
'blogengine/static/css/code.css',
'blogengine/static/css/main.css',
],
dest: 'blogengine/static/css/style.css'
}
},
uglify: {
dist: {
src: [
'blogengine/static/bower_components/jquery/jquery.js',
'blogengine/static/bower_components/bootstrap/dist/js/bootstrap.js'
],
dest: 'blogengine/static/js/all.min.js'
}
},
cssmin: {
dist: {
src: 'blogengine/static/css/style.css',
dest: 'blogengine/static/css/style.min.css'
}
}
});
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-cssmin');
grunt.registerTask('default', ['concat', 'uglify', 'cssmin']);
};

You’ll also need to change the paths in your base HTML file to point to the minified versions:

<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{% block title %}My Django Blog{% endblock %}</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="alternate" type="application/rss+xml" title="Blog posts" href="/feeds/posts/" >
<!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
{% load staticfiles %}
<link rel="stylesheet" href="{% static 'css/style.min.css' %}">
</head>
<body>
<!--[if lt IE 7]>
<p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
<![endif]-->
<!-- Add your site or application content here -->
<div id="fb-root"></div>
<script>(function(d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
js = d.createElement(s); js.id = id;
js.src = "//connect.facebook.net/en_GB/all.js#xfbml=1";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>
<div class="navbar navbar-static-top navbar-inverse">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#header-nav">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{% url 'blogengine:index' %}">My Django Blog</a>
</div>
<div class="collapse navbar-collapse" id="header-nav">
<ul class="nav navbar-nav">
{% load flatpages %}
{% get_flatpages as flatpages %}
{% for flatpage in flatpages %}
<li><a href="{{ flatpage.url }}">{{ flatpage.title }}</a></li>
{% endfor %}
<li><a href="/feeds/posts/">RSS feed</a></li>
<form action="/search" method="GET" class="navbar-form navbar-left">
<div class="form-group">
<input type="text" name="q" placeholder="Search..." class="form-control"></input>
</div>
<button type="submit" class="btn btn-default">Search</button>
</form>
</ul>
</div>
</div>
</div>
<div class="container">
{% block header %}
<div class="page-header">
<h1>My Django Blog</h1>
</div>
{% endblock %}
<div class="row">
{% block content %}{% endblock %}
</div>
</div>
<div class="container footer">
<div class="row">
<div class="span12">
<p>Copyright &copy; {% now "Y" %}</p>
</div>
</div>
</div>
<script src="{% static 'js/all.min.js' %}"></script>
<!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
<script>
(function(b,o,i,l,e,r){b.GoogleAnalyticsObject=l;b[l]||(b[l]=
function(){(b[l].q=b[l].q||[]).push(arguments)});b[l].l=+new Date;
e=o.createElement(i);r=o.getElementsByTagName(i)[0];
e.src='//www.google-analytics.com/analytics.js';
r.parentNode.insertBefore(e,r)}(window,document,'script','ga'));
ga('create','UA-XXXXX-X');ga('send','pageview');
</script>
</body>
</html>

Now, run the Grunt task:

$ grunt

And collect the static files:

$ python manage.py collectstatic

You’ll also want to add your node_modules folder to your gitignore:

venv/
*.pyc
db.sqlite3
reports/
htmlcov/
.coverage
node_modules/

Then commit your changes:

$ git add .
$ git commit -m 'Optimised static assets'

Now, our package.json will cause a problem - it will mean that this app is mistakenly identified as a Node.js app. To prevent this, create the .slugignore file:

package.json

Then commit your changes and push them up:

$ git add .slugignore
$ git commit -m 'Added slugignore'
$ fab deploy

If you check, your site should now be loading the minified versions of the static files.

That’s our site done! As usual I’ve tagged the final commit with lesson-8.

Sadly, that’s our final instalment over with! I hope you’ve enjoyed these tutorials, and I look forward to seeing what you create with them.

Recent Posts

Decorating Service Classes

Simplify Your Tests With Anonymous Classes

Adding React to a Legacy Project

Do You Still Need Jquery?

An Approach to Writing Golden Master Tests for PHP Web Applications

About me

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