Using Django with cron for batch jobs

26 April 2007

The MVC-style separation in Django makes it easy to write Python scripts to handle automated jobs. They can query the database using the Django database API and create and manipulate objects. Essentially everything you can do in a view, except you don’t have a request object since you aren’t in the context of an http request.

For my current project I have a couple of scripts that are executed by cron. One script emails users telling them what’s new. It gets this information by calling methods on various models. Another script uses the database API to gather some statistics on the state of the system (e.g. how many registered users and who were the last five) and sends them by email.

Here’s some code.

#! /usr/bin/env python

import sys
import os

def setup_environment():
    pathname = os.path.dirname(sys.argv[0])
    sys.path.append(os.path.abspath(pathname))
    sys.path.append(os.path.normpath(os.path.join(os.path.abspath(pathname), '../')))
    os.environ['DJANGO_SETTINGS_MODULE'] = 'settings'

# Must set up environment before imports.
setup_environment()

from django.core.mail import send_mail
from django.contrib.auth.models import User

def main(argv=None):
    if argv is None:
        argv = sys.argv

    # Do stuff here.  For example, send an email reporting number of users.
    
    user_count = User.objects.count()
    message = 'There are now %d registered users.' % user_count

    send_mail('System report',
              message,
              'report@example.com',
              ['user@example.com'])


if __name__ == '__main__':
    main()

The interesting bit is in setup_environment which adds paths to the Python path so when you import modules used in your project (e.g. import myapp.models) Python knows where to find them.

In my case, I put my scripts in to the Django project directory (i.e. next to settings.py). The path to the Django project needs to be in the Python path so that imports work. setup_environment uses sys.path.append to add the path of the script that is executing (e.g. /srv/www/mydjangoproject/status_report.py) and its parent directory. This is sufficient to make imports in the project work.

The other thing that needs to be set is the DJANGO_SETTINGS_MODULE environment variable. It’s just the name of the settings module, by default “settings”.

There may be a better way to do this setup, but it seems to work ok.

Now you can call the script directly or from cron. If you’re sending emails from your script, you can write straight text templates (rather than html) and use django.template.loader.render_to_string to render the template with some variables in to a string to send by email.

from django.template.loader import render_to_string

email_body = render_to_string('reports/email/score_report.txt',
                              {'user': user,
                               'top_score': top_score})

Update: Here’s a cleaner way from Jared. It uses the setup_environ function from django.core.management.

Update: James Bennett has an excellent write-up on standalone Django scripts.

Filed under: Django — Scott @ 5:45 pm

Filtering model objects with a custom manager

I have various models in my current Django project that can be marked as “deleted”. They’re still in the database, but filtered out and as far as most of the code is concerned, they no longer exist. A simple way to do this is with a custom manager that filters the query set.

# Custom manager filters out deleted items.
class NotDeletedManager(models.Manager):
    def get_query_set(self):
        return super(NotDeletedManager, self).get_query_set().filter(deleted=False)

class Article(models.Model):
    author = models.ForeignKey(Author, related_name='articles')
    ...
    deleted = models.BooleanField(default=False)
    # Use custom manager as default manager for this model.
    objects = NotDeletedManager()

I can then refer to Article.objects.all() and get only those articles not marked deleted. Likewise, author.articles.all() gets articles for a given author that are not marked deleted.

Next I wanted to be able to access deleted items. Time for another manager:

class DeletedManager(models.Manager):
    def get_query_set(self):
        return super(DeletedManager, self).get_query_set().filter(deleted=True)

class Article(models.Model):
    ...
    objects = NotDeletedManager()
    deleted_articles = DeletedManager()

It seems a bit redundant, but two managers is not bad. Especially since they can be used with any model that has a “deleted” field.

Next I wanted to filter related objects based on whether they belong to a deleted object. For example, if an author is deleted, all articles by that author should not show up in articles.

class NotDeletedArticleOrAuthorManager(models.Manager):
    def get_query_set(self):
        return super(NotDeletedArticleOrAuthorManager, self).get_query_set().filter(deleted=False, author__deleted=False)

class Article(models.Model):
    ...
    objects = NotDeletedArticleOrAuthorManager()

This is getting a bit messy. I’d rather pass parameters to the manager telling it what to filter out. Something like…

class FilterManager(models.Manager):
    "Custom manager filters standard query set with given args."
    def __init__(*args, **kwargs):
        self.args = args
        self.kwargs = kwargs

    def get_query_set(self):
        return super(FilterManager, self).get_query_set().filter(*self.args, **self.kwargs)

class Article(models.Model):
    ...
    objects = FilterManager(deleted=False, author__deleted=False)
    deleted_articles = FilterManager(deleted=True)

Unfortunately, this doesn’t work. The filters are applied when I call the manager directly like Article.objects.all(), but no filters are applied if I go through a relationship like author.articles.all() (returns deleted articles as well).

The problem is the related managers (see the source django/db/models/fields/related.py e.g. ForeignRelatedObjectsDescriptor.__get__). They create a class that dynamically inherits from the models default manager (our custom manager) then return a new instance of it. The new instance doesn’t have any parameters passed to its __init__ method, so there are no filters.

My workaround is to use a function to return a custom class that has the filter parameters saved in the class, so they don’t get passed to an instance. When a related manager inherits from this class, it still has our filters in place.

def get_filter_manager(*args, **kwargs):
    class FilterManager(models.Manager):
        "Custom manager filters standard query set with given args."
        def get_query_set(self):
            return super(FilterManager, self).get_query_set().filter(*args, **kwargs)
    return FilterManager()

class Article(models.Model):
    ...
    objects = get_filter_manager(deleted=False, author__deleted=False)
    deleted_articles = get_filter_manager(deleted=True)

The class is declared in the function and gets args and kwargs from the function. They are therefore “baked in” to the class and don’t need to be passed as parameters to __init__.

This is only minimally tested, but seems to work. Perhaps there is a better way, but for now this is what I have.

Filed under: Django — Scott @ 4:28 pm