C H A P T E R   7

image

Finishing the Weblog

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.

Comments and 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.

Implementing Model Inheritance and Abstract Models

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:

  • Concrete inheritance: This is what many people think of when they imagine how subclassing a model works. In this pattern, one model that subclasses another will create a new database table that links back to the original “parent” class's table with a foreign key. Instances of the subclassed model will behave as if they have both the fields defined on the “parent” model and the fields defined on the subclassed model itself (under the hood, Django will pull information from both tables as needed).
  • Abstract inheritance: When you define a new model class and fill in its options using the inner 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 Application

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:

  1. Add it to INSTALLED_APPS and run syncdb.
  2. Add a new URL pattern to route to its default URLConf.
  3. Set up any needed templates.

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.

Performing 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="image
{% 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.

Retrieving Lists of Comments for Display

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>

Moderating Comments

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:

  • Whenever a comment is posted on an entry that's more than a certain number of days old (say, 30), automatically mark it nonpublic. The vast majority of comment spam targets old content, partly because most content is old and partly because it's less likely to be noticed by a site administrator.
  • Use a statistical spam-detection system. Akismet (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:

  • Just as you've defined a custom 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.
  • You could edit or replace the view that handles the comment submission and put the spam filtering there.

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?

Using Signals and the Django Dispatcher

As it turns out, there is a way to do that. Django includes a module called django.dispatch that provides two things:

  • A way for any piece of code in Django, or in one of your own applications, to advertise the fact that something happened
  • A way for any other piece of code to “listen” for a specific event happening and take some action in response

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:

  1. You imported the dispatcher and an instance of Signal, defined in django.db.models.signals.
  2. You wrote a function that prints the message. The arguments it receives—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.
  3. You registered the function using the signal's connect() method, to be called when the Entry model sends the post_save signal.
  4. When the 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.
  5. The dispatcher called your custom function.

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.

Building the Automatic 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)

Adding Akismet Support

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:

  • The text of the comment to check
  • Some additional “metadata” about the comment, in a dictionary
  • A boolean (True or False) argument telling it whether to try to work out additional metadata on its own

The 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/" image
%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.

Sending E-mail Notifications

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:

  1. Fill in, at a minimum, the settings 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.
  2. Fill in the setting DEFAULT_FROM_EMAIL to serve as the default From address for automated e-mail sending.
  3. Import an e-mail–sending function from 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.mailmail_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/" image
%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.

Using Django's Comment-Moderation Features

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:

  • It's heavily tied to the models used in your weblog application. For example, it assumes the existence of a field named 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.
  • The particular rules you're using—moderate all comments after 30 days, submit to Akismet, e-mail copies of comments to site administrators—are hard-coded into the application. This means it'd be difficult to reuse this application in situations where those rules aren't appropriate.

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, image
self).moderate(comment, content_object)
        if already_moderated:
            return True
        akismet_api = Akismet(key=settings.AKISMET_API_KEY,
                              blog_url="http:/%s/" % image
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.

Adding Feeds

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:

  • A set of classes that represent feeds and that can be subclassed for easy customization
  • A view that knows how to work with these classes to generate and serve the appropriate XML

To see how it works, let's start by setting up an Atom feed for the latest entries posted to the weblog.

Creating the LatestEntriesFeed Class

Go 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:

  1. The tag: string
  2. The domain for the item, followed by a comma, followed by a relevant date for the item, followed by a colon
  3. An identifying string that is unique for that domain and date

For 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).

Generating Entries by Category: A More Complex Feed Example

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:

  • The list of items in the feed should, of course, know how to figure out which Category it's looking at and ensure that it returns only entries from that category.
  • Several of the metadata fields—the title of the feed, the link, and so on—will need to change dynamically based on the 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.

Looking Ahead

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset