Now that you've got a solid set of templates and, more important, a solid understanding of Django's template system, it's time to finish up the weblog with the final two features: a comments system with moderation and syndication feeds for entries and links.
Although Django provides applications—django.contrib.comments
and django.contrib.syndication
—that handle the basic functionality for both of these features, you're going to go beyond that a bit, customizing and extending their features as you go. This will involve a bit of Python code and a bit of templating, but as you'll see, it's nowhere near as much code as you'd have to write to implement these features from scratch. So let's dive right in.
django.contrib.comments
You've already seen that django.contrib
contains some useful applications. Both the administrative interface and the authentication system you're using come from applications in contrib
, as well as the flat-pages application you used in your simple CMS. In general, it's a good idea to look there before starting to write something on your own. As I write this, django.contrib
contains 17 applications, and there are plans to expand it to include more open source applications from the Django community. Even if something in contrib
doesn't do exactly what you need, you'll often find something that you can augment or something that can make a tricky bit of code simpler.
Commenting is no exception to this. The baseline comments system you're going to build on is bundled as django.contrib.comments
. It supports the basic features you'll need to get a commenting system up and running, and it provides a foundation for building additional features.
Included in django.contrib.comments
is a pair of models—BaseCommentAbstractModel
and Comment
—that represent a useful pattern in Django development: abstract models with concrete subclasses.
So far, you've been writing models that are subclasses of Django's built-in basic model class, but Django also supports models that subclass from other model classes. It allows you to use either of two common patterns when you're doing such subclassing:
class Meta
declaration, you can add the attribute abstract=True
. When you do this, Django will not create a table for that model, and you won't be able to directly create or query for instances of that model. However, any subclasses of that model (as long as they don't also declare abstract=True
) will create their own tables, and will add columns for the fields from the abstract model as well.In other words, concrete inheritance creates one table for each model, as usual. Abstract inheritance creates only one table, the table for the subclass, and places all of the fields inside it.
Generally, concrete inheritance is useful when you want to extend the fields or features of a preexisting model. Abstract inheritance, on the other hand, is useful when you have a set of common fields or methods (or both) that you'd like to have on multiple models without defining them over and over again.
Django's bundled comments
application takes advantage of abstract inheritance to provide a basic model—BaseCommentAbstractModel
—that defines a set of common fields needed for nearly any type of commenting, and declares it to be abstract. It also provides a second model—Comment
—that subclasses this abstract model and fleshes it out with a specific set of features.
Installing the comments system is easy. Open up your Django project's settings file (settings.py
), and add the following line in the INSTALLED_APPS
list:
'django.contrib.comments',
Next run python manage.py syncdb
, and Django will install its models. If you fire up the development server and visit the administrative interface, you'll see a new Comments section listing the Comment
model. (Because the abstract model it subclasses can't be directly instantiated or queried, there's no admin interface for it.)
In the project's root URLConf file (urls.py
), add one new URL pattern:
(r'^comments/', include('django.contrib.comments.urls')),
You've seen this pattern several times now, and in general, this is the hallmark of a well-built Django application. Installing it shouldn't involve any more work than the following:
INSTALLED_APPS
and run syncdb
.Writing an application to work this way out of the box is an extremely powerful technique because it allows even very complex sites to be built quickly out of reusable applications, with each supplying one particular piece of functionality. Keeping this pattern in mind as you write your own applications will help you produce high-quality, useful applications. In Chapter 11, you'll look at some techniques for building in configurability and flexibility beyond this style of basic setup.
To get started with the comments application, you'll need to show a comment form for visitors to fill out. Let's start with that.
Open up the entry-detail template—coltrane/entry_detail.html
—and go to the main content block, which looks like this:
{% block content %}
<h2>{{ object.title }}</h2>
{{ object.body_html|safe }}
{% endblock %}
Go ahead and add a header that will distinguish the comment form:
{% block content %}
<h2>{{ object.title }}</h2>
{{ object.body_html|safe }}
<h2>Post a comment</h2>
{% endblock %}
Now you just need to display the form. The comments system includes a custom template-tag library that, among other things, can do that for you. The tag library is called comments
, so you'll need to load it with the {% load %}
tag:
{% block content %}
<h2>{{ object.title }}</h2>
{{ object.body_html|safe }}
<h2>Post a comment</h2>
{% load comments %}
{% endblock %}
Now, the tag you want is called {% render_comment_form %}
, and its syntax looks like this:
{% render_comment_form for object %}
In other words, this tag just wants a variable containing the specific object that the comment will be attached to, which will be available in the entry_detail
template as the variable {{ object }}
. So you can fill in the tag like this:
{% block content %}
<h2>{{ object.title }}</h2>
{{ object.body_html|safe }}
<h2>Post a comment</h2>
{% load comments %}
{% render_comment_form for object %}
{% endblock %}
Note that you don't put the braces around object
here. The braces, as in {{ object }}
, are used only when you want to output the value of the variable. They're not needed in a template tag, and in fact, they'll cause an error. Template tags can resolve variables on their own (as you'll see in Chapter 10 when you write a few tags that do that).
Now, go visit an entry, and you'll see the comment form show up. If you fill in a comment and click the Preview button, you'll see a preview of your comment, displayed via a default template included with Django. In fact, django.contrib.comments
includes enough basic default templates to support everything you'll be doing for now; templates are included for previewing and posting comments, and also for more advanced features like comment moderation.
But when you start deploying live Django applications, you'll want to customize these templates to match your site's layout. The default templates are bundled with the comments
application, and reside in the directory contrib/comments/templates/comments
inside your copy of Django.
To get a feel for how this customization will work, make a new directory named comments
inside your project's templates
directory, and copy the preview.html
template from the Django comments
application into this directory.
Most of this template's contents are concerned with displaying the comment form and any submission problems. For example, some fields in the form are required, and this template will display an error message if they're left blank (you'll learn more about Django's form-handling system in Chapter 9). There's one section, though, that displays the actual preview of the comment (if there were no errors from the form). It looks like this:
<h1>{% trans "Preview your comment" %}</h1>
<blockquote>{{ comment|linebreaks }}</blockquote>
<p>
{% trans "and" %} <input type="submit" name="submit" class="submit-post" value="
{% trans "Post your comment" %}" id="submit" /> {% trans "or make changes" %}:
</p>
There's an unfamiliar tag here—trans
—but you won't need to worry about it yet. Django includes what's known as “internationalization” facilities, which allow pieces of text to be marked for translation into other languages. If translations are available and a visitor's web browser indicates a preferred language, they'll be substituted in automatically. The trans
tag performs this function for templates.
The actual comment's contents are displayed through a filter called linebreaks
, which simply translates line breaks in the comment's text into HTML paragraph tags. Because you're already using Markdown to process the weblog's content, it'd be nice to let visitors use it for their comments as well. Django provides a built-in template filter that can handle this.
To enable the filter, you'll need to add one more entry to your INSTALLED_APPS
setting: django.contrib.markup
, which contains tools for working with common text-to-HTML translation systems (including Markdown). You won't need to run syncdb
, because this application provides no models; you just need to list the application in INSTALLED_APPS
so that Django will let you use the template filters it provides.
Once you've done that, change your copy of the preview.html
template so that the portion displaying the comment's contents looks like this:
{% load markup %}
<blockquote>{{ comment|markdown: "safe" }}</blockquote>
This will apply Markdown to the comment's contents, and will also enable Markdown's “safe mode,” which strips any raw HTML tags out of the comment before generating the final HTML to display. This is important because Django's normal automatic escaping won't apply with this filter; the markdown
filter is meant to return HTML, so it disables automatic escaping for its output. Using the safe mode means that any malicious HTML a user tries to submit will still be removed, and will not result in a breach of your site's security.
All you need to do now is retrieve the comments and display them. Just as django.contrib.comments
provides a custom template tag for showing the comment form, it provides a tag that can handle comment retrieval. The syntax for it looks like this:
{% get_comment_list for object as comment_list %}
So you can make use of the tag in your entry_detail.html
template like this:
<h2>Comments</h2>
{% load markup %}
{% get_comment_list for object as comment_list %}
{% for comment in comment_list %}
<p>On {{ comment.submit_date|date:"F j, Y" }},
{{ comment.name }} said:</p>
{{ comment.comment|markdown:"safe" }}
{% endfor %}
Django's default Comment
model automatically sets up the attribute name
to return the appropriate value; if the comment was posted by a logged-in user, name
will be that user's username. If the comment was posted by someone who wasn't logged in, name
will be whatever name that person supplied in the comment form.
So the full content block of your entry_detail.html
template now looks like this:
{% block content %}
<h2>{{ object.title }}</h2>
{{ object.body_html|safe }}
<h2>Comments</h2>
{% load comments %}
{% load markup %}
{% get_comment_list for object as comment_list %}
{% for comment in comment_list %}
<p>On {{ comment.submit_date|date:"F j, Y" }},
{{ comment.name }} said:</p>
{{ comment.comment|markdown:"safe" }}
{% endfor %}
<h2>Post a comment</h2>
{% render_comment_form for object %}
{% endblock %}
If you'd like to add a line in the sidebar to show the number of comments on the entry, the get_comment_count
tag will retrieve it for you. You might use it like this:
{% load comments %}
{% get_comment_count for object as comment_count %}
<p>So far, this entry has {{ comment_count }}
comment{{ comment_count|pluralize }}.</p>
Out of the box, Django covers most of what you want for commenting: an easy way to let visitors post comments and then pull out a list of the comments that are “attached” to a particular object. But given the proliferation of comment spam around the Web in recent years, you're still going to want some sort of automatic moderation system to screen incoming comments. For that, you'll need to write some code.
Both of the comment models in django.contrib.comments
define a BooleanField
called is_public
, and that's what a moderation system should use. Now, there are a couple of very effective ways to filter comment spam:
http://akismet.com/
) is the gold standard for this, with a history of more than five billion spam comments to draw on for analysis. Best of all, Akismet offers a web-based API that estimates whether a comment is spam or not.On my personal blog, I get around six thousand spam comments a month. The combination of these two filtering techniques has, so far, prevented all but one or two of them from showing up publicly.
So you want to find some way to hook into the comment-submission system and automatically apply the two filtering techniques to set is_public=False
on the new comment whenever it looks like it'll be spam. There are a couple of obvious ways to do this:
save()
method on some of our own models, you could go to the comment models in Django and edit them to include a custom save()
method that does the spam filtering.But both of these methods have major drawbacks. Either you're editing code that comes with Django (which will make it harder to upgrade down the road and might cause debugging problems because you'll have a nonstandard Django codebase), or you're duplicating code Django has already provided in order to add a small modification.
Wouldn't it be nice if you could just write some of your own code, and then hook into Django somehow to make sure it runs at the right moment?
As it turns out, there is a way to do that. Django includes a module called django.dispatch
that provides two things:
The way this works is pretty simple: django.dispatch
provides a class called Signal
, which represents the occurrence of some event. Each instance of Signal
has two important methods:
send
: Calling this method means “this event has happened."connect
: Calling this method lets you register a function that will be called whenever the signal is “sent."For a simple example, go to the cms
project directory and start a Python interpreter by typing python manage.py shell. Then type the following:
>>> from coltrane.models import Entry
>>> from django.db.models.signals import post_save
>>> def print_save_message(sender, instance, **kwargs):
. . . print "An entry was just saved!"
>>> post_save.connect(print_save_message, sender=Entry)
Now, query for an Entry
and save it:
>>> e = Entry.objects.all()[0]
>>> e.save()
Your Python interpreter will suddenly print “An entry was just saved!” Here's what happened:
Signal
, defined in django.db.models.signals
.sender
and instance
—will end up being the Entry
model class (which is going to “send” the signal you're listening for) and the specific Entry
object being saved. You're not doing anything with these arguments, but when you build the comment-moderation system you'll see how they can be used. The function also accepts **kwargs
, indicating that it can accept any keyword arguments. This is necessary because different signals provide different arguments.connect()
method, to be called when the Entry
model sends the post_save
signal.Entry
was saved, code within Django—built into the base Model
class that all your models inherit from—used the post_save
signal's send()
method to send it.Django defines about a dozen signals you can use immediately, and it's easy to define and use your own as well. You can also do some tricks with the dispatcher that are more complex, but what you've seen so far is all you'll actually need in order to build an effective comment moderator.
To build your comment-moderation system, you'll write a function that knows how to look at an incoming comment and figure out whether it's spam. Then you'll use the dispatcher to ensure that function is called each time a new comment is about to be saved. Just as you used the post_save
signal in the previous example, there's a pre_save
signal you can use to run code before an object is saved.
The first thing you want to do when you get a new comment is look at the entry it's being posted to. If that entry is more than, say, 30 days old, you'll just set its is_public
field to False
and not bother with any further checks. This is where the instance
argument to your custom function comes into play. From the new comment object that's about to be saved, you can determine the entry it's being posted on. Here's what the code looks like:
import datetime
def moderate_comment(sender, instance, **kwargs):
if not instance.id:
entry = instance.content_object
delta = datetime.datetime.now() - entry.pub_date
if delta.days > 30:
instance.is_public = False
So far, this function is pretty straightforward. You only check things if the comment—which will be the object in the instance
argument—doesn't yet have an id
, meaning it hasn't been saved to the database. If it does have an id
, presumably it's already been checked. Checking it again would make it hard for a site administrator to ever manually approve a comment, because the comment would keep going through this process, being marked nonpublic on each save.
First you use the instance
argument to find the entry that the comment is being posted on. Django's comment model has an attribute called content_object
, which returns the object that the comment pertains to.
Next you subtract the entry's pub_date
from the current date and time. Python's datetime
class is set up so that this will work, and the result is an instance of a class called timedelta
, which has attributes representing the number of days, hours, and so on between the two datetime
objects involved.
Next, you check the days
attribute on that timedelta
object. If it's greater than 30, you set the new comment's is_public
field to False
.
At this point, you could already hook up the function, and it would do a good job of preventing spam:
from django.contrib.comments.models import Comment
from django.db.models import signals
signals.pre_save.connect(moderate_comment, sender=Comment)
Now let's add in the second layer of spam prevention: statistical spam analysis by the Akismet web service. The first thing you'll need is an Akismet API key—all access to Akismet's service requires this key. Luckily, it's free for personal, noncommercial use. Just follow the instructions on the Akismet web site (http://akismet.com/personal/
) to get a key. Once you've got it, open up the Django settings file for the cms
project and add the following line to it:
AKISMET_API_KEY = 'your API key goes here'
By making this a custom setting, you'll be able to reuse the Akismet spam filtering on other sites, even if they have different API keys.
Akismet is a web-based service. You send information about a comment to the service using an HTTP request, and it sends back an HTTP response telling you whether Akismet thinks that the comment is spam. You could build up the code necessary to do this, but—as you'll often find when working with Python—someone else has already done it and made the code available for free.
In this case, it's a module called akismet
, which is available from the author, Michael Foord, at his web site: www.voidspace.org.uk/python/akismet_python.html
. Go ahead and download and unpack it (it should come in a .zip file). This will give you a file named akismet.py
that you can put on your Python import path (ideally, in the same location as the coltrane
directory that holds the weblog application).
The akismet
module includes a class called Akismet
that handles the API. This class has two methods you'll be using: one called verify_key()
, which ensures you're using a valid API key, and one called comment_check()
, which submits a comment to Akismet and returns True
if Akismet thinks the comment is spam.
So the first thing you'll need to do is import the Akismet
class:
from akismet import Akismet
The Akismet API requires both the API key you've been assigned and the address of the site you're submitting the comment from. You could hard-code the URL of your site in here, but that would hurt the reusability of the code. A better option is to use Django's bundled sites
framework (it lives in django.contrib.sites
), which provides a model that represents a particular web site and knows which site is currently active.
You'll recall that back in Chapter 2, when you set up the simple CMS, you edited a Site
object so it would “know” where you were running the development server. Whenever you're running with this database and settings file, you can get that Site
object with the following:
from django.contrib.sites.models import Site
current_site = Site.objects.get_current()
This works because the Site
model has a custom manager that defines the get_current()
method. The Site
object it returns has a field called domain
, which you can use to fill in the information Akismet wants. This information is the keyword argument blog_url
when you're creating an instance of the API (along with the API key, which comes from your settings file and is the keyword argument key
):
from django.conf import settings
from django.contrib.sites.models import Site
akismet_api = Akismet(key=settings.AKISMET_API_KEY,
blog_url="http://%s/" %Site.objects.get_current().domain)
Then you can check your API key with the verify_key()
method. If it's valid, you can submit a comment for analysis with the comment_check()
method. The comment_check()
method expects three arguments:
True
or False
) argument telling it whether to try to work out additional metadata on its ownThe text of the comment is easy enough to get, because it's a field on the comment itself. The dictionary of metadata needs to have at least four values in it, even if some of them are blank (because you don't necessarily know what they are). These values are the type of comment (which, for simple uses like this, is simply the string comment
), the HTTP Referer
header value, the IP address from which the comment was sent (also a field on the comment
model), and the HTTP User-Agent
of the commenter. Finally, you'll tell the akismet
module to go ahead and work out any additional metadata it can find. More information means better accuracy, especially because the akismet
module can, under some server setups, find some useful information automatically. The code looks like this (Akismet's comment_check()
method returns True
if it thinks the comment is spam):
from django.utils.encoding import smart_str
if akismet_api.verify_key():
akismet_data = { 'comment_type': 'comment',
'referrer': '',
'user_ip': instance.ip_address,
'user-agent': '' }
if akismet_api.comment_check(smart_str(instance.comment),
akismet_data,
build_data=True):
instance.is_public = False
Remember that Django uses Unicode strings everywhere, so whenever you use an external API, you should convert Unicode strings to bytestrings by using the helper function django.utils.encoding.smart_str()
.
But there's a problem here: you don't know the values of the HTTP Referer
and User-Agent
headers. Although they aren't required, these values can help Akismet make a more accurate determination of whether a comment is spam. Fortunately, there's a way to get those values.
So far, you've just been using the standard signals—pre_save
and post_save
—sent by Django any time a model is saved. But django.contrib.comments
was designed with use cases like this one in mind, so it also defines a couple of its own custom signals that provide more information. The signal you'll want to use here is django.contrib.comments.signals.comment_will_be_posted
, which passes along not only the model class (Comment
) and the actual comment object, but also the Django HttpRequest
object in which the comment is being submitted. This means that you'll have access to all of the request headers and that you can fill out all the information Akismet asks for.
To use this signal, first you'll need to import it:
from django.contrib.comments.signals import comment_will_be_posted
Then you'll need to change the definition of the moderate_comment
function to accommodate the arguments that this signal sends:
def moderate_comment(sender, comment, request, **kwargs):
Now you can rewrite the section of the code that sends the comment to Akismet for the spam check:
if akistmet_api.verify_key():
akismet_data = { 'comment_type': 'comment',
'referrer': request.META['HTTP_REFERER'],
'user_ip': comment.ip_address,
'user-agent': request.META['HTTP_USER_AGENT'] }
if akismet_api.comment_check(smart_str(instance.comment),
akismet_data,
build_data=True):
comment.is_public = False
Note that because you've rewritten the function to accept an argument named comment
, you need to change anything that referred to it as instance
. Also note that the values for the HTTP headers reside in request.META
, which is a dictionary. You can identify most HTTP headers in request.META
by converting their names to uppercase and prefixing them with HTTP_
. This means, for example, that the HTTP Referer
header becomes HTTP_REFERER
in request.META
.
Once you put it all together, the complete comment-moderation function, with both age-based and statistical Akismet filtering, looks like this:
import datetime
from akismet import Akismet
from django.conf import settings
from django.contrib.comments.models import Comment
from django.contrib.comments.signals import comment_will_be_posted
from django.contrib.sites.models import Site
from django.utils.encoding import smart_str
def moderate_comment(sender, comment, request, **kwargs):
if not comment.id:
entry = comment.content_object
delta = datetime.datetime.now() - entry.pub_date
if delta.days > 30:
comment.is_public = False
else:
akismet_api = Akismet(key=settings.AKISMET_API_KEY,
blog_url="http:/%s/"
%Site.objects.get_current().domain)
if akismet_api.verify_key():
akismet_data = { 'comment_type': 'comment',
'referrer': request.META['HTTP_REFERER'],
'user_ip': comment.ip_address,
'user-agent': request.META['HTTP_USER_AGENT'] }
if akismet_api.comment_check(smart_str(comment.comment),
akismet_data,
build_data=True):
comment.is_public = False
comment_will_be_posted.connect(moderate_comment, sender=Comment)
The best place to put this is near the bottom of coltrane/models.py
so that the connect()
line will be read and executed when the weblog's models are imported. This also does away with the need for at least one of the imports—the import datetime
line—because it's already been imported in that file.
And it would be evaluated again if you later did another import like this:
from search import models
Django's manage.py
utility changes your Python import path for convenience, and in so doing, makes both of the preceding lines work. So it's not unusual that a project ends up having imports in both forms like the ones shown. Unfortunately, this means that if you have a piece of code you want to run only once—like the connect()
line, because you only want that function to register once—it will instead be run once for each different way the module gets imported.
It's best to pick a single style of import and use it consistently. As a general rule, I typically stick to the way the application is listed in my INSTALLED_APPS
setting. For example, if I have cms.search
in INSTALLED_APPS
, I always do the import as from cms.search import models
.
A lot of weblogging and CMS systems that allow commenting also include a feature that automatically notifies site administrators whenever a new comment is posted. This is useful because it lets them keep up with active discussions, and also lets them spot any problems—a troublemaking commenter, arguments that get out of hand, or just the occasional bit of spam that slips through the filter. You've seen how easy it is to use Django's dispatcher to add extra functionality when a comment is posted, so let's go ahead and add e-mail notifications as a finishing touch.
Sending e-mail from within Django is fairly easy to do, and breaks down into a few simple steps:
EMAIL_HOST
and EMAIL_PORT
in the Django settings file. These will be used to determine the e-mail (SMTP) server Django connects to in order to send mail. If your mail server requires a username and password to send mail, fill in EMAIL_HOST_USER
and EMAIL_HOST_PASSWORD
as well. If your mail server requires a secure TLS connection, set EMAIL_USE_TLS
to True
.DEFAULT_FROM_EMAIL
to serve as the default From address for automated e-mail sending.django.core.mail
and call it. Most often you'll use django.core.mail.send_mail()
, which takes a subject, message, From address, and list of recipients, in that order.Now, you could use send_mail()
and hard-code one or more recipients for comment notifications. But once again, this would hurt the reusability of your code. Two different sites using this application might want two different sets of people receiving comment notifications.
Fortunately, there's an easy solution. In the Django settings file are two settings—ADMINS
and MANAGERS
—that help you deal with situations like this. The ADMINS
setting should be a list of programmers or other technical people who should receive notifications about problems with your site. When you deploy in production, Django will automatically e-mail debugging information to the people listed in ADMINS
whenever a server error occurs. The MANAGERS
setting, on the other hand, should be a list of people who aren't necessarily programmers, but who are involved in the management of the site. Each of these settings expects a format like the following:
MANAGERS = (('Alice Jones', '[email protected]'),
('Bob Smith', '[email protected]'))
In other words, it's a tuple, or list of tuples, where each tuple contains a name and an e-mail address. When these are filled in, two functions in django.core.mail
—mail_admins()
and mail_managers()
—can be used as a shortcut to send an e-mail to those people.
So to add comment notification, you can do something like the following:
from django.core.mail import mail_managers
email_body = "%s posted a new comment on the entry '%s'."
mail_managers("New comment posted",
email_body % (comment.name,
comment.content_object))
This will send an e-mail to everyone listed in the MANAGERS
setting, notifying them of the new comment.
And so you have the final version of your moderate_comment
function:
from akismet import Akismet
from django.conf import settings
from django.contrib.comments.models import Comment
from django.contrib.comments.signals import comment_will_be_posted
from django.contrib.sites.models import Site
from django.core.mail import mail_managers
from django.utils.encoding import smart_str
def moderate_comment(sender, comment, request, **kwargs):
if not comment.id:
entry = comment.content_object
delta = datetime.datetime.now() - entry.pub_date
if delta.days > 30:
comment.is_public = False
else:
akismet_api = Akismet(key=settings.AKISMET_API_KEY,
blog_url="http:/%s/"
%Site.objects.get_current().domain)
if akismet_api.verify_key():
akismet_data = { 'comment_type': 'comment',
'referrer': request.META['HTTP_REFERER'],
'user_ip': comment.ip_address,
'user-agent': request.META['HTTP_USER_AGENT'] }
if akismet_api.comment_check(smart_str(comment.comment),
akismet_data,
build_data=True):
comment.is_public = False
email_body = "%s posted a new comment on the entry '%s'."
mail_managers("New comment posted",
email_body % (comment.name,
comment.content_object))
comment_will_be_posted.connect(moderate_comment, sender=Comment)
Once this is in place, you won't need to do anything further. The get_comment_list
tag you're using to retrieve comments for display in your templates is smart enough to take the is_public
field into account when it retrieves the comments, so any comment with is_public
set to False
will be automatically excluded.
At this point, you have a comment-moderation system that implements a particular set of moderation rules, but unfortunately it suffers from a couple of major problems:
pub_date
on the object that a comment will be attached to. This means that if you ever add new models to your project (in either the weblog application or another application) and allow comments on them, the moderation system might break.What would be ideal is some sort of generic system that lets you decide which comments get subjected to moderation rules and lets you specify the moderation rules on a per-model basis. This would let you set up moderation for comments on weblog entries, for example, but perhaps turn it off for other types of content. Such a system would also let you tailor the specific moderation rules to each particular type of content.
From what you've seen already in the moderation system you just built, you could probably work out how to build such a generic system. Mostly, it'd be a matter of checking what type of content an incoming comment will “attach” to, and then applying the specific moderation rules for that type of content. But because this is something that's needed fairly often, Django provides that infrastructure for you, allowing you to write only the code necessary to implement your own specific moderation rules.
The code for Django's built-in moderation system resides in django.contrib.comments.moderation
, which provides two important bits of code:
django.contrib.comments.moderation.moderator
acts as a sort of central registry for all the comment-moderation rules you're using, and keeps track of which set of rules goes with which type of content.django.contrib.comments.moderation.CommentModerator
lets you specify the rules for one particular type of content.In many ways, Django's moderation system works similarly to how its administrative interface works. With the admin, you write a subclass of Django's ModelAdmin
class, describe the options you want, and register it with the administrative interface. With comment moderation, you write a subclass of CommentModerator
, describe the options you want, and register it with the moderation system.
For example, instead of using the comment-moderation system you just built, you could place the following code at the bottom of coltrane/models.py
, and Django's moderation system would automatically mark comments nonpublic 30 days after an entry's publication and automatically e-mail your site staff whenever a comment is posted on an entry:
from django.contrib.comments.moderation import CommentModerator, moderator
class EntryModerator(CommentModerator):
auto_moderate_field = 'pub_date'
moderate_after = 30
email_notification = True
moderator.register(Entry, EntryModerator)
This will work because django.contrib.comments.moderation.moderator
listens for the signals sent whenever a comment is submitted; it then looks up the appropriate rules and applies them.
Currently (as of Django 1.1), the built-in moderation system doesn't support Akismet, so you'll need a tiny bit of custom code to make that work. Here's how it looks:
from akismet import Akismet
from django.conf import settings
from django.contrib.comments.moderation import CommentModerator, moderator
from django.utils.encoding import smart_str
class EntryModerator(CommentModerator):
auto_moderate_field = 'pub_date'
moderate_after = 30
email_notification = True
def moderate(self, comment, content_object, request):
already_moderated = super(EntryModerator,
self).moderate(comment, content_object)
if already_moderated:
return True
akismet_api = Akismet(key=settings.AKISMET_API_KEY,
blog_url="http:/%s/" %
Site.objects.get_current().domain)
if akismet_api.verify_key():
akismet_data = { 'comment_type': 'comment',
'referrer': request.META['HTTP_REFERER'],
'user_ip': comment.ip_address,
'user-agent': request.META['HTTP_USER_AGENT'] }
return akismet_api.comment_check(smart_str(comment.comment),
akismet_data,
build_data=True)
return False
moderator.register(Entry, EntryModerator)
The preceding code defines a method named moderate()
on your CommentModerator
subclass. That method will be passed three arguments: the comment that's being posted, the content object that it will be attached to (in the case of a weblog entry), and the HTTP request in which the comment is being posted. The first thing to do here is use super()
to call the moderate()
method of the parent class (CommentModerator
), because it might be able to determine that the comment should be moderated without having to send it to Akismet. The return value of moderate()
is either True
or False
; if it's True
, the comment is moderated (marked nonpublic).
If the parent class's moderate()
method returns False
, then you can send the comment to Akismet and return whatever value comes back from Akismet's comment_check()
method (because it also returns True
when it thinks a comment is spam). But note the final line of your moderate()
method: it simply returns False
. This is important because you might not get a useful response from Akismet (if your API key is invalid, for example), but your moderate()
method is still required to return a value of either True
or False
. Choosing which to use as a “last-resort” value for that sort of situation is up to you; this line of code will be executed only if the Akismet verify_key()
check fails.
The last feature you want for your weblog is the ability to have RSS or Atom feeds of your entries and links. You also want to have custom feeds that handle, for example, entries in a specific category. Creating this functionality from scratch—by writing view functions that retrieve a list of entries and render a template that creates the appropriate XML instead of an HTML page—wouldn't be too terribly hard. But because this is a common need for web sites, Django again provides some help to automate the process via the bundled application django.contrib.syndication
. At its core, django.contrib.syndication
provides two things:
To see how it works, let's start by setting up an Atom feed for the latest entries posted to the weblog.
LatestEntriesFeed
ClassGo into the coltrane
directory and create a new empty file, called feeds.py
. At the top, add the following lines:
from django.utils.feedgenerator import Atom1Feed
from django.contrib.sites.models import Site
from django.contrib.syndication.feeds import Feed
from coltrane.models import Entry
current_site = Site.objects.get_current()
Now you can start writing a feed class for the latest entries. Call it LatestEntriesFeed
. It will be a subclass of the django.contrib.syndication.feeds.Feed
class you're importing here.
First you need to fill in some required metadata. This is going to be an Atom feed, so several elements are required. (RSS feeds require less metadata, but it's a good idea to include this information anyway, because additional metadata is more useful for people who want to collect and process information from feeds.) Here's an example:
class LatestEntriesFeed(Feed):
author_name = "Bob Smith"
copyright = "http://%s/about/copyright/" % current_site.domain
description = "Latest entries posted to %s" % current_site.name
feed_type = Atom1Feed
item_copyright = "http://%s/about/copyright/" % current_site.domain
item_author_name = "Bob Smith"
item_author_link = "http://%s/" % current_site.domain
link = "/feeds/entries/"
title = "%s: Latest entries" % current_site.name
Go ahead and fill in appropriate information for your own name and relevant metadata. Note that while most of the items here will automatically vary according to the current site, I've hard-coded values into the author_name
, item_author_name
, and link
fields.
For reusability across a wide variety of sites, you can subclass this feed class to override only those values. Or, if you have a function that can determine the correct value for a given site, you can fill that in. (For example, you might use a reverse URL lookup to get the link
field.) For a complete list of these fields and what you're allowed to put in each one, check the full documentation for django.contrib.syndication
, which is online at www.djangoproject.com/documentation/syndication_feeds/
.
Now you need to tell the feed how to find the items it's supposed to contain—the latest 15 live entries, in our case. You do this by adding a method named items()
to the feed class, which will return those entries:
def items(self):
return Entry.live.all()[:15]
Each item needs to have a date listed in the feed. You accomplish that using a method called item_pubdate()
, which will receive an object as an argument and return a date
or datetime
object to use for that object. (The Feed
class will automatically format this appropriately for the type of feed being used.) In the case of an Entry
, that's just the value of the pub_date
field:
def item_pubdate(self, item):
return item.pub_date
Each item also needs to have a unique identifier, called a GUID (short for globally unique identifier). This can be the id
field from the database, but it's generally better to use something less transient. If you were to migrate to a new server or a different database, the id
values might change during the transition, and the GUID for a particular entry would change in the process.
For a situation like this, the ideal solution is something called a tag URI. A tag URI (uniform resource identifier) provides a standard way of generating a unique identifier for some Internet resource, in a way that won't change as long as that Internet resource continues to exist at the same address. If you're interested in the full details of the standard, tag URIs are specified by IETF RFC 4151 (www.faqs.org/rfcs/rfc4151.html
), but the basic idea is that a tag URI for an item consists of three parts:
tag:
stringFor the date, you'll use the pub_date
field of each entry. For the unique identifying string, you'll use the result of its get_absolute_url()
method, because that's required to be unique.
The result, for example, is that the entry at www.example.com/2008/jan/12/example-entry/
would end up with a GUID of
tag:example.com,2008-01-12:/2008/jan/12/example-entry/
This meets all the requirements for a feed GUID. To implement this, you simply define a method on your feed class called item_guid()
. Again, it receives an object as its argument:
def item_guid(self, item):
return "tag:%s,%s:%s" % (current_site.domain,
item.pub_date.strftime('%Y-%m-%d'),
item.get_absolute_url())
One final thing you can add to your feed is a list of categories for each item. This will help feed aggregators categorize the items you publish. You can do this by defining a method called item_categories
:
def item_categories(self, item):
return [c.title for c in item.categories.all()]
A full example feed class, then, looks like this:
class LatestEntriesFeed(Feed):
author_name = "Bob Smith"
copyright = "http://%s/about/copyright/" % current_site.domain
description = "Latest entries posted to %s" % current_site.name
feed_type = Atom1Feed
item_copyright = "http://%s/about/copyright/" % current_site.domain
item_author_name = "Bob Smith"
item_author_link = "http://%s/" % current_site.domain
link = "/feeds/entries/"
title = "%s: Latest entries" % current_site.name
def items(self):
return Entry.live.all()[:15]
def item_pubdate(self, item):
return item.pub_date
def item_guid(self, item):
return "tag:%s,%s:%s" % (current_site.domain,
item.pub_date.strftime('%Y-%m-%d'),
item.get_absolute_url())
def item_categories(self, item):
return [c.title for c in item.categories.all()]
Now you can set up a URL for this feed. Go to the urls.py
file in the cms
project directory, and add two things. First, near the top of the file (above the list of URL patterns), add the following import
statement and dictionary definition:
from coltrane.feeds import LatestEntriesFeed
feeds = { 'entries': LatestEntriesFeed }
Next, add a new pattern to the list of URLs:
(r'^feeds/(?P<url>.*)/$',
'django.contrib.syndication.views.feed',
{ 'feed_dict': feeds }),
This will route any URL beginning with /feeds/
to the view in django.contrib.syndication
, which handles feeds. The dictionary you set up maps between feed slugs, like entries
, and specific feed classes.
One final thing you need to do is create two templates. django.contrib.syndication
uses the Django template system to render the title and main body of each item in the feed so that you can decide how you want to present each type of item. So go to the directory where you've been keeping templates for this project, and inside it create a new directory called feeds
. Inside that create two new files, called entries_title.html
and entries_description.html
. (The names to use come from the combination of the feed's slug—in this case, entries
—and whether the template is for the item's title or its description.) Each of these templates will have access to two variables:
obj
: This is a specific item being included in the feed.site
: This is the current Site
object, as returned by Site.objects.get_current()
.So for item titles, you can simply use each entry's title. In the entries_title.html
template, place the following:
{{ obj.title }}
For the description, you'll use the same trick that you used for the entry-archive templates you set up in the last chapter. Display the excerpt_html
field if it has any content; otherwise, display the first 50 words of body_html
. So in entries_description.html
, fill in the following:
{% if obj.excerpt_html %}
{{ obj.excerpt_html|safe }}
{% else %}
{{ obj.body_html|truncatewords_html:"50"|safe }}
{% endif %}
Remember that Django's template system automatically escapes HTML in variables, so you still have to use the safe
filter. With the templates in place, you can launch the development server and visit the URL /feeds/entries/
to see the feed of latest entries in the weblog.
Writing a feed for the latest links should be easy at this point. Try writing the LatestLinksFeed
class yourself and set it up correctly. (Remember that links don't have categories associated with them, so you should either leave out the item_categories()
method or rewrite it to return a list of tags.) A full example is in the sample code associated with this book, so refer to it if you get lost (you can find the code samples for this chapter in the Source Code/Download area of the Apress web site at www.apress.com
).
Now, you'd like to also offer categorized feeds so that readers who are interested in one or two specific topics can subscribe to feeds that list only entries from the categories they like. But this is a bit trickier because it raises two problems:
Category
it's looking at and ensure that it returns only entries from that category.Django's Feed
class provides a way to deal with this, though. A Feed
subclass can define a method called get_object()
, which will be passed an argument containing the bits of the URL that came after the slug you registered the feed with, as a list. So, for example, if you registered a feed with the slug categories
and visited the URL /feeds/categories/django/
, your feed's get_object()
would be passed an argument containing the single-item list ["django"]
. From there you can look up the category.
Let's start by adding two items to the import
statements at the top of your feeds.py
file so that it now looks like this:
from django.core.exceptions import ObjectDoesNotExist
from django.utils.feedgenerator import Atom1Feed
from django.contrib.sites.models import Site
from django.contrib.syndication.feeds import Feed
from coltrane.models import Category, Entry
This gives you access to the Category
model, as well as to ObjectDoesNotExist
, an exception class that Django defines. You can use this if someone tries to visit a URL for a nonexistent category's feed. (When you raise ObjectDoesNotExist
, Django will return an HTTP 404 “File Not Found” response.)
Now you can begin writing your feed class. Because a lot of it is similar to the existing LatestEntriesFeed
, you'll just subclass it and change the parts that need to be changed:
class CategoryFeed(LatestEntriesFeed):
def get_object(self, bits):
if len(bits) != 1:
raise ObjectDoesNotExist
return Category.objects.get(slug__exact=bits[0])
This will either raise ObjectDoesNotExist
or return the Category
you need to display entries for. Now you can set up the feed's title, description, and link, by defining methods with those names that receive the Category
object as an argument (Django's feed system is smart enough to recognize that it needs to pass that object when calling the methods):
def title(self, obj):
return "%s: Latest entries in category '%s'" % (current_site.name,
obj.title)
def description(self, obj):
return "%s: Latest entries in category '%s'" % (current_site.name,
obj.title)
def link(self, obj):
return obj.get_absolute_url()
You can change the items()
method as well. Again, Django's feed system is smart enough to know that it needs to be passed the Category
object, and it will make sure that happens:
def items(self, obj):
return obj.live_entry_set()[:15]
Remember that you defined the live_entry_set()
method on the Category
model so that it would return only entries with “live” status.
And that's that. Now your feeds.py
file should look like this:
from django.core.exceptions import ObjectDoesNotExist
from django.utils.feedgenerator import Atom1Feed
from django.contrib.sites.models import Site
from django.contrib.syndication.feeds import Feed
from coltrane.models import Category, Entry
current_site = Site.objects.get_current()
class LatestEntriesFeed(Feed):
author_name = "Bob Smith"
copyright = "http://%s/about/copyright/" % current_site.domain
description = "Latest entries posted to %s" % current_site.name
feed_type = Atom1Feed
item_copyright = "http://%s/about/copyright/" % current_site.domain
item_author_name = "Bob Smith"
item_author_link = "http://%s/" % current_site.domain
link = "/feeds/entries/"
title = "%s: Latest entries" % current_site.name
def items(self):
return Entry.live.all()[:15]
def item_pubdate(self, item):
return item.pub_date
def item_guid(self, item):
return "tag:%s,%s:%s" % (current_site.domain,
item.pub_date.strftime('%Y-%m-%d'),
item.get_absolute_url())
def item_categories(self, item):
return [c.title for c in item.categories.all()]
class CategoryFeed(LatestEntriesFeed):
def get_object(self, bits):
if len(bits) != 1:
raise ObjectDoesNotExist
return Category.objects.get(slug__exact=bits[0])
def title(self, obj):
return "%s: Latest entries in category '%s'" % (current_site.name,
obj.title)
def description(self, obj):
return "%s: Latest entries in category '%s'" % (current_site.name,
obj.title)
def link(self, obj):
return obj.get_absolute_url()
def items(self, obj):
return obj.live_entry_set()[:15]
You can register this feed by changing the import
line in your project's urls.py
file from
from coltrane.feeds import LatestEntriesFeed
to
from coltrane.feeds import CategoryFeed, LatestEntriesFeed
and by adding one line to the feeds
dictionary. Change it from
feeds = { 'entries': LatestEntriesFeed }
to
feeds = { 'entries': LatestEntriesFeed,
'categories': CategoryFeed }
Finally, you'll want to set up the templates feeds/categories_title.html
and feeds/categories_description.html
. Because they're just displaying entries, feel free to copy and paste the contents of the two templates you used for the LatestEntriesFeed
.
Writing feed classes that display entries or links by tag will follow the same pattern. Examples are included in the sample code you can download for this book, but again, I recommend that you try it yourself before peeking to see how it's done.
And with that, you've implemented all the features you set out to have for your weblog. But, more important, you've covered a huge amount of territory within Django: models, views, URL routing, templating and custom template extensions, comments, and Django's signal system and syndication feeds. You should already be feeling a lot more comfortable working with Django and writing what would—if you were developing from scratch without Django's help—be some fairly complex features.
So give yourself a pat on the back because you've got a lot of useful Django knowledge under your belt now. Also take some time to work with the weblog application you've developed. Try to think of a feature you'd like to add, and then see if you can work out how to add it.
When you're ready, the next chapter will start a brand-new application: a code-sharing site with some useful social features, which will highlight Django's form-processing system for user-submitted content and show off some advanced uses of the database API.