Yet another tutorial for building a blog using Python and Django - part 4

Published by at 29th March 2012 9:29 pm

Welcome back! In this tutorial we'll continue extending our Django-powered blogging engine. We'll add the capability to assign blog posts to categories, and comment on posts. We'll also generate an RSS feed for our blog posts.

Categories are somewhat tougher to implement than most of what we've done beforehand. One category can be assigned to many blog posts, and many categories can be assigned to one blog post, so this relationship is described as a "many to many relationship" when drawing up the database structure. What it means is that you can't directly map categories onto posts and vice versa - you have to create an intermediate database table for the relationship between posts and categories.

Here's what your models.py should look like:

1from django.db import models
2from django.contrib.auth.models import User
3
4# Create your models here.
5class Category(models.Model):
6 title = models.CharField(max_length=200)
7 slug = models.SlugField(max_length=40, unique=True)
8 description = models.TextField()
9
10 class Meta:
11 verbose_name_plural = "Categories"
12
13 def __unicode__(self):
14 return self.title
15
16 def get_absolute_url(self):
17 return "/categories/%s/" % self.slug
18
19class Post(models.Model):
20 title = models.CharField(max_length=200)
21 pub_date = models.DateTimeField()
22 text = models.TextField()
23 slug = models.SlugField(max_length=40, unique=True)
24 author = models.ForeignKey(User)
25 categories = models.ManyToManyField(Category, blank=True, null=True, through='CategoryToPost')
26
27 def __unicode__(self):
28 return self.title
29
30 def get_absolute_url(self):
31 return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
32
33class CategoryToPost(models.Model):
34 post = models.ForeignKey(Post)
35 category = models.ForeignKey(Category)

We're adding quite a bit of new code here. First of all we're defining a new model called Category. Each category has a title, a description, and a slug (so we can have a dedicated page for each category). As usual, we define methods for unicode and get_absolute_url, but also note the class Meta. Here we're defining some metadata for the class (ie, data about the data). The only thing we do here is essentially telling the admin interface that the plural of Category is not "Categorys" but "Categories".

Then, in Post we add an additional field called Category, which we define as a ManyToManyField. Note the parameters passed through - we're saying here that a post need not be assigned a category, and that CategoryToPost should be used as an intermediate table to link posts to categories.

Finally, we define the aforementioned CategoryToPost model, which has two fields, post and category. Both of these are foreign keys, mapping to a blog post and a category respectively. By creating entries in this table, a link can be created between a post and a category.

With our model changed, it's time to update our admin.py as well:

1import models
2from django.contrib import admin
3from django.contrib.auth.models import User
4
5class CategoryAdmin(admin.ModelAdmin):
6 prepopulated_fields = {"slug": ("title",)}
7
8class CategoryToPostInline(admin.TabularInline):
9 model = models.CategoryToPost
10 extra = 1
11
12class PostAdmin(admin.ModelAdmin):
13 prepopulated_fields = {"slug": ("title",)}
14 exclude = ('author',)
15 inlines = [CategoryToPostInline]
16
17 def save_model(self, request, obj, form, change):
18 obj.author = request.user
19 obj.save()
20
21admin.site.register(models.Post, PostAdmin)
22admin.site.register(models.Category, CategoryAdmin)

Here we define a new class called CategoryAdmin, which details how we're changing the admin interface for Category from the defaults generated from the fields provided. The only change we make here is that we prepopulate the slug field from the title, much like we did with blog posts.

Next, we define an inline for the relationships between categories and post, called CategoryToPostInline. This is a new concept - essentially it means that the category to post relationships can be defined in another model's admin interface. We define the model this applies to, and that by default we will only add one additional field for adding categories when writing or editing a post (though users can add as many as they wish, or none). Note that the model this is based on is admin.TabularInline - this represents a tabular layout. If you prefer, you can use an alternative layout by using StackedInline instead.

Then, in PostAdmin we add our newly declared CategoryToPostInline to the PostAdmin class as an inline. Finally, at the bottom we register Category with the admin interface, so we can create and manage categories easily.

With that done, it's time to edit our views.py:

1# Create your views here.
2from django.shortcuts import render_to_response
3from django.core.paginator import Paginator, EmptyPage
4from blogengine.models import Post, Category
5
6def getPosts(request, selected_page=1):
7 # Get all blog posts
8 posts = Post.objects.all().order_by('-pub_date')
9
10 # Add pagination
11 pages = Paginator(posts, 5)
12
13 # Get the specified page
14 try:
15 returned_page = pages.page(selected_page)
16 except EmptyPage:
17 returned_page = pages.page(pages.num_pages)
18
19 # Display all the posts
20 return render_to_response('posts.html', { 'posts':returned_page.object_list, 'page':returned_page})
21
22def getPost(request, postSlug):
23 # Get specified post
24 post = Post.objects.filter(slug=postSlug)
25
26 # Display specified post
27 return render_to_response('single.html', { 'posts':post})
28
29def getCategory(request, categorySlug, selected_page=1):
30 # Get specified category
31 posts = Post.objects.all().order_by('-pub_date')
32 category_posts = []
33 for post in posts:
34 if post.categories.filter(slug=categorySlug):
35 category_posts.append(post)
36
37 # Add pagination
38 pages = Paginator(category_posts, 5)
39
40 # Get the category
41 category = Category.objects.filter(slug=categorySlug)[0]
42
43 # Get the specified page
44 try:
45 returned_page = pages.page(selected_page)
46 except EmptyPage:
47 returned_page = pages.page(pages.num_pages)
48
49 # Display all the posts
50 return render_to_response('category.html', { 'posts': returned_page.object_list, 'page': returned_page, 'category': category})

Here we import the Category model as well as the Post model. Then, the only additional change we need to make is to add a brand new getCategory view function. Note that this is quite similar to the getPosts function - we set up pagination in the same way, and rather than get all the posts, we get just those in the specified category. Also note that we're using the template category.html rather than posts.html here, and we pass through category as well as posts and page when we return the render_to_response.

The next change we need to make is adding category.html. Go into your template directory and save the code below as category.html:

1{% include 'header.html' %}
2 <h1>Posts for {{ category.title }}</h1>
3 {% if posts %}
4 {% for post in posts %}
5 <h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
6 {{ post.text }}
7 {% endfor %}
8 <br />
9 {% if page.has_previous %}
10 <a href="/{{ page.previous_page_number }}/">Previous Page</a>
11 {% endif %}
12 {% if page.has_next %}
13 <a href="/{{ page.next_page_number }}/">Next Page</a>
14 {% endif %}
15 {% else %}
16 <p>No posts matched</p>
17 {% endif %}
18{% include 'footer.html' %}

With our template in place, the last step is to add an appropriate URLconf. Edit urls.py to look like this:

1from django.conf.urls.defaults import patterns, include, url
2
3# Uncomment the next two lines to enable the admin:
4from django.contrib import admin
5admin.autodiscover()
6
7urlpatterns = patterns('',
8 # Examples:
9 # url(r'^$', 'blog.views.home', name='home'),
10 # url(r'^blog/', include('blog.foo.urls')),
11
12 # Uncomment the admin/doc line below to enable admin documentation:
13 # url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
14
15 # Uncomment the next line to enable the admin:
16 url(r'^admin/', include(admin.site.urls)),
17
18 # Home page
19 url(r'^$', 'blogengine.views.getPosts'),
20 url(r'^(?P<selected_page>\d+)/?$', 'blogengine.views.getPosts'),
21
22 # Blog posts
23 url(r'^\d{4}/\d{1,2}/(?P[-a-zA-Z0-9]+)/?$', 'blogengine.views.getPost'),
24
25 # Categories
26 url(r'^categories/(?P<categorySlug>\w+)/?$', 'blogengine.views.getCategory'),
27 url(r'^categories/(?P<categorySlug>\w+)/(?P<selected_page>\d+)/?$', 'blogengine.views.getCategory'),
28
29 # Flat pages
30 url(r'', include('django.contrib.flatpages.urls')),
31)

Now, if you run python manage.py syncdb again, the category system should be up and running.

The next step is to add the facility to handle comments. Again, Django has its own application built in for handling comments, so go into settings.py and enter the following under INSTALLED_APPS:

'django.contrib.comments',

Then run python manage.py syncdb again to generate the appropriate database tables. You'll also need to amend urls.py to provide a dedicated URL for comments:

1 # Comments
2 url(r'^comments/', include('django.contrib.comments.urls')),

Place this before the URLconf for the flat pages.

Comments can be attached to any type of content, but we only want to attach them to blog posts, and they should only be visible in the single post template. But first of all, let's add a comment count to posts in posts.html and category.html. Replace posts.html with this:

1{% include 'header.html' %}
2 {% load comments %}
3 {% if posts %}
4 {% for post in posts %}
5 <h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
6 {{ post.text }}
7 {% get_comment_count for post as comment_count %}
8 <h3>Comments: {{ comment_count }}</h3>
9 {% endfor %}
10 <br />
11 {% if page.has_previous %}
12 <a href="/{{ page.previous_page_number }}/">Previous Page</a>
13 {% endif %}
14 {% if page.has_next %}
15 <a href="/{{ page.next_page_number }}/">Next Page</a>
16 {% endif %}
17 {% else %}
18 <p>No posts matched</p>
19 {% endif %}
20{% include 'footer.html' %}

And replace category.html with this:

1{% include 'header.html' %}
2 {% load comments %}
3 <h1>Posts for {{ category.title }}</h1>
4 {% if posts %}
5 {% for post in posts %}
6 <h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
7 {{ post.text }}
8 {% get_comment_count for post as comment_count %}
9 <h3>Comments: {{ comment_count }}</h3>
10 {% endfor %}
11 <br />
12 {% if page.has_previous %}
13 <a href="/{{ page.previous_page_number }}/">Previous Page</a>
14 {% endif %}
15 {% if page.has_next %}
16 <a href="/{{ page.next_page_number }}/">Next Page</a>
17 {% endif %}
18 {% else %}
19 <p>No posts matched</p>
20 {% endif %}
21{% include 'footer.html' %}

The only significant changes here are that at the top we load comments, and underneath the post text we get the comment count for each post as the variable comment_count, then we display it underneath.

Now, we want to go further with our single post template. As well as a comment count, we want to add the actual comments themselves. Finally, we need a form for adding comments - in theory you can use the admin interface for doing this, but it's very unlikely you'd want to do so. Open up single.html and edit it to look like this:

1{% include 'header.html' %}
2 {% load comments %}
3 {% for post in posts %}
4 <h1><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h1>
5 <h3>{{ post.pub_date }}</h3>
6 {{ post.text }}
7 <h3>By {{ post.author.first_name }} {{ post.author.last_name }}</h3>
8 <h3>Categories: {% for category in post.categories.all %} {{ category.title }} {% endfor %}</h3>
9 {% get_comment_count for post as comment_count %}
10 <h3>Comments: {{ comment_count }}</h3>
11 <ol>
12 {% get_comment_list for post as comments %}
13 {% for comment in comments %}
14 <li>{{ comment }}</li>
15 {% endfor %}
16 </ol>
17 {% render_comment_form for post %}
18 {% endfor %}
19 <br />
20 {% if page.has_previous %}
21 <a href="/{{ page.previous_page_number }}/">Previous Page</a>
22 {% endif %}
23 {% if page.has_next %}
24 <a href="/{{ page.next_page_number }}/">Next Page</a>
25 {% endif %}
26{% include 'footer.html' %}

This includes the same changes as the other two templates, so we load comments and display the comment count. Afterwards, we get the comment list for this post as comments, and then loop through the comments, showing them in an ordered list. Afterwards, we then use render_comment_form to show the default comment form for this post. If you'd prefer to create your own comment form, you can use get_comment_form instead to get a form object you can use in the template.

You'll also need to make some minor changes to the view to get the form working. Save single.html and open blogengine/views.py and add the following line of code to your import statements:

from django.template import RequestContext

Then, amend the final line of the getPost function as follows:

return render_to_response('single.html', { 'posts':post}, context_instance=RequestContext(request))

The reason this needs to be changed is that the comment form includes the {% csrf_token %} tag, which requires information from the request object, and in order to do so rather than the default context, you need to pass through a RequestContext object instead, but don't worry too much about the details.

If you now ensure the development server is running and visit a blog post, you should now see that you can post comments. If you want to enhance this very basic comment form, take a look at the excellent documentation on the Django website. Alternatively, there are a number of third-party comment services, such as Disqus and IntenseDebate that can handle comments for you and just require you to paste a snippet of code into whatever template you want to enable comments on, and these may be more convenient.

Finally for this lesson, as promised, we'll implement our RSS feed. Again, there's an application bundled with Django that will do this - the syndication framework. Open settings.py and paste the following line in at the bottom of your INSTALLED_APPS:

'django.contrib.syndication',

Save the file and run python manage.py syncdb to add the appropriate tables to your database. Then, we need to add a URLconf for the RSS feed. We'll allow a consistent naming scheme for RSS feeds, so this will be /feeds/posts, and if you wanted to you could add /feeds/comments, for instance. Add this to you urls.py, before the url for flat pages:

1 # RSS feeds
2 url(r'^feeds/posts/$', PostsFeed()),

We'll also need to tell urls.py where to find PostsFeed(). In this case, we're going to put it in the view, so add this import line near the top:

from blogengine.views import PostsFeed

Now open blogengine/views.py and add the following line to the import statements at the top:

from django.contrib.syndication.views import Feed

Then add the following class declaration to the bottom:

1class PostsFeed(Feed):
2 title = "My Django Blog posts"
3 link = "feeds/posts/"
4 description = "Posts from My Django Blog"
5
6 def items(self):
7 return Post.objects.order_by('-pub_date')[:5]
8
9 def item_title(self, item):
10 return item.title
11
12 def item_description(self, item):
13 return item.text

This is pretty simple. We import the Feed class from thew views provided by the syndication framework, then we base PostsFeed on Feed. We set the title, the link for the feed, and a description for the feed. Then we get the last 5 Post objects in reverse chronological order, and we define each item's title as the post title. and each item's description as the text of the post. From here' it's pretty easy to see how you could create feeds based on comments, or pretty much any other object that might exist in the database.

And with that done, our blogging engine is pretty-much feature-complete. We have blog posts with comments, categories, an RSS feed, and flat pages, but the look and feel of the site definitely needs some attention. Next time, we'll make our blogging engine look a little nicer. Once again, the code is available on GitHub in case you find that more convenient.