Blogging on App Engine, part 5: Tagging
Posted by Nick Johnson | Filed under coding, app-engine, tech, bloggart
This is part of a series of articles on writing a blogging system on App Engine. An overview of what we're building is here.
Following on from our previous post, today we're going to deal with tagging. There are three components to adding tagging support to our blog:
- Adding tags to the model and the post/edit interface.
- Generating listing pages of posts with a given tag.
- Adding tags and links to the listing pages on individual posts.
We'll tackle these in order. First, adding tags to the model and to the add/edit post interface. Add the following property immediately after 'body' on the BlogPost class (in models.py):
tags = db.StringListProperty()
That's it. No, really. Thanks to our use of ModelForms, our admin interface now has support for adding and editing posts with tags. Try it, if you wish. One slight caveat: The interface expects tags to be separated by newlines, rather than by commas. That's something we could address with a custom widget, at a later stage.
Next, the listing pages. Nearly all the functionality required to generate listings of posts with a given tag is identical to that required to generate the existing archive pages - all that changes is the filter on the query, and the paths they're rendered to. With that in mind, we'll refactor the existing IndexContentGenerator. Rename it to ListingContentGenerator, and add a couple of attributes and a new method to the class:
class ListingContentGenerator(ContentGenerator):
path = None
"""The path for listing pages."""
first_page_path = None
"""The path for the first listing page."""
@classmethod
def _filter_query(cls, resource, q):
"""Applies filters to the BlogPost query.
Args:
resource: The resource being generated.
q: The query to act on.
"""
pass
The 'path' and 'first_page_path' attributes allow us to specify the paths to store generated pages to on a class-by-class basis. The _filter_query method will allow us to select only the entities we want for a given listing page.
Once again, we need to refactor part of the generate_resource method. Added sections are highlighted in yellow:
def generate_resource(cls, post, resource, pagenum=1, start_ts=None):
import models
q = models.BlogPost.all().order('-published')
if start_ts:
q.filter('published <=', start_ts)
cls._filter_query(resource, q)
posts = q.fetch(config.posts_per_page + 1)
more_posts = len(posts) > config.posts_per_page
path_args = {
'resource': resource,
}
path_args['pagenum'] = pagenum - 1
prev_page = cls.path % path_args
path_args['pagenum'] = pagenum + 1
prev_page = cls.path % path_args
template_vals = {
'posts': posts[:config.posts_per_page],
'prev_page': prev_page if pagenum > 1 else None,
'next_page': next_page if more_posts else None,
}
rendered = utils.render_template("listing.html", template_vals)
path_args['pagenum'] = pagenum
static.set(cls.path % path_args, rendered, config.html_mime_type)
if pagenum == 1:
static.set(cls.first_page_path % path_args, rendered,
config.html_mime_type)
if more_posts:
deferred.defer(cls.generate_resource, None, resource, pagenum + 1,
posts[-1].published)
The changes this time are minor: We call _filter_query at the appropriate time, to give subclasses an opportunity to add filter conditions to the query, and we use the new path and first_page_path attributes with a format string to generate the URI paths to store the pages at.
Specifying a subclass to implement the existing archive generation functionality is straightforward:
class IndexContentGenerator(ListingContentGenerator):
"""ContentGenerator for the homepage of the blog and archive pages."""
path = '/page/%(pagenum)d'
first_page_path = '/'
@classmethod
def get_resource_list(cls, post):
return ["index"]
generator_list.append(IndexContentGenerator)
And specifying a subclass to implement the tag page generation is nearly as simple:
class TagsContentGenerator(ListingContentGenerator):
"""ContentGenerator for the tags pages."""
path = '/tags/%(resource)s/%(pagenum)d'
first_page_path = '/tags/%(resource)s'
@classmethod
def get_resource_list(cls, post):
return post.tags
@classmethod
def _filter_query(cls, resource, q):
q.filter('tags =', resource)
Note that, for the first time, our get_resource_list method returns more than one entry: Here, it returns the list of tags associated with the post. Thanks to the dependency regeneration functionality we defined way back in part 3, this list will be used to regenerate only the resources needed: If we add or remove a tag, that tag's pages are regenerated, while if we modify the post's title or summary, all the tags containing that post are regenerated.
The third and final part of adding tag support is to add the list of tags, with links to the tag pages, to the individual blog posts. Open up post.html, and add this block immediately after the h2 tag containing the post's title:
<p>
Posted by {{config.author_name}}
{% if post.tags %}
| Filed under
{% for tag in post.tags %}
<a href="/tag/{{tag|escape}}">{{tag|escape}}</a>{% if not forloop.last %},{% endif %}
{% endfor %}
{% endif %}
</p>
Add the same block of code in listing.html, after the h2 tag for each post and we're done!
You can see the latest version of the blog at http://bloggart-demo.appspot.com/ and view the source here.
In the next post, we'll add site search and Disqus comment support to bloggart.
Previous Post Next Post