Friday, June 10, 2011

Generic Django Model Templates


Update 23 Jun 2011: I have renamed the django-model-filters package django-model-blocks. It now lets you easily override the template used for a given model. Check out the changes on Github or PyPI.


Tonight I'm writing my first Django custom filter. The problem I'm trying to solve is that I want generic templates. For a given model I want to be able to set up browseable index and detail pages with minimal effort. As it stands now, say I have the following model:


    ...
    class PepulatorModel (models.Model):
        serial_number = IntegerField()
        height = IntegerField()
        width = IntegerField()
        manufacture_date = DateTimeField()
        color = CharField(max_length=32)
    
        def __unicode__(self):
            return u'Pepulator #%s' % self.serial_number
    ...

Now say I want my users to be able to browse my pepulators in a simple way, with the following caveats:

  • They cannot edit pepulators, only view (rules out admin app)
  • I want to define the URL structure (rules out databrowse) to be something like:
    
        http://www.mysite.com/pepulators/
        http://www.mysite.com/pepulators/?color=red
        http://www.mysite.com/pepulators/012345/
    
  • I want to specify the base template so that it integrates well with the rest of my project (also rules out databrowse)

Currently, I can use the generic views ListView and DetailView, but I still have to write templates that go something like this:


    {% extends base.html %}
    
    {% block content %}
        <header>
            <h1>Pepulator #{{ pepulator.serial_number }}</h1>
        </header>
        
        <div>
            <span>Serial Number</span>
            <p>{{ pepulator.serial_number }}</p>
        </div>
        <div>
            <span>Height</span>
            <p>{{ pepulator.height }}</p>
        </div>
        <div>
            <span>Width</span>
            <p>{{ pepulator.width }}</p>
        </div>
        <div>
            <span>Manufacturer</span>
            <p>{{ pepulator.manufacturer }}</p>
        </div>
        <div>
            <span>Color</span>
            <p>{{ pepulator.color }}</p>
        </div>
    {% endblock %}

Okay, a bit verbose, but it's not going to kill me. However, now say I want to change some of the fields on my model. Well, then I have to remember to change the fields in my template as well (error-prone — this is why you don't violate DRY without good reason).

All I wanted was a simple view of my model!

So, I considered making an app that was leaner than databrowser and just provided generic templates to go with generic views. I found myself having to extend the generic views anyway, though, because there's no way to access a model instance's fields and field names without explicitly feeding them to the template's context. Then, I gleaned some inspiration from uni_forms: I'll make filters!

Now my plan is to be able to say, using the example of the Pepulator detail view above:


    {% extends base.html %}
    
    {% block content %}
        {{ pepulator|as_detail_html }}
    {% endblock %}

Sublime. (This must exist somewhere; but for now, I can't find it.)

So, I start off by creating my app


    $ python manage.py startapp generic_templates

Now, from the documentation on creating custom tags and filters, I see I should create a templatetags direcotry in my app. In here I'll put an __init__.py file and a module called generic_filters. This way, when I'm done, to use the filters, I'll put near the top of my template file:


    {% load generic_filters %}

I decided to start with the detail filter (as_detail_html), and to write a test first. I know generally what I want this to do, so I write the following test:


    """
    Test the generic filters
    """

    import datetime

    from django.test import TestCase
    from mock import Mock

    from django.db.models import Model, IntegerField, DateTimeField, CharField
    from django.template import Context, Template
    from generic_templates.templatetags import generic_filters as gf

    class DetailHtmlFilterTest (TestCase):

        def setUp(self):
            # Create a sample model
            class PepulatorModel (Model):
                serial_number = IntegerField(primary_key=True)
                height = IntegerField()
                width = IntegerField()
                manufacture_date = DateTimeField()
                color = CharField(max_length=32)
            
                def __unicode__(self):
                    return u'Pepulator #%s' % self.serial_number
            
            # Create a model instance
            now = datetime.datetime.now()
            self.m = PepulatorModel(
                serial_number = 123456,
                height = 25,
                width = 16,
                manufacture_date = now,
                color = 'chartreuse',
            )
            
            # Mock Django's get_template so that it doesn't load a real file;
            # instead just return a template that allows us to verify the context
            gf.get_template = Mock(
                return_value=Template('{{ instance|safe }}:{{ fields|safe }}'))
        
        
        def test_model_format(self):
            """Tests that a given model is formatted as expected."""
            
            expected_detail = (u"Pepulator #123456:[('serial number', 123456),"
              " ('height', 25), ('width', 16), ('manufacture date', %r),"
              " ('color', 'chartreuse')]") % self.m.manufacture_date
            detail = gf.as_detail_html(self.m)
            
            gf.get_template.assert_called_with('object_detail.html')
            self.assertEqual(detail, expected_detail)

In short, set up a model and an easy template, and check that the template is filled in correctly. Of course, since I haven't yet written my filter, this fails.

This (as_detail_html) was a straightforward method to write, but I did get tripped up because of the poor documentation available on Models' Meta classes. Here's the first go at the filter:


    from django.template import Context, Template
    from django.template.loader import get_template

    def as_detail_html(instance):
        """
        Template filter that returns the given instance as a template-formatted
        block.  Inserts two objects into the context:
          ``instance`` - The model instance
          ``fields`` - A list of (name, value)-pairs representing the instance's
                       fields
        """
        template = get_template('object_detail.html')
        fields = [(field.verbose_name, getattr(instance, field.name)) 
                  for field in instance._meta.fields]
        context = Context({'instance':instance, 'fields':fields})
        return template.render(context)

One other thing: I actually want to be able to use the filter in my templates, not call it directly in my code. I'm new here, so I write another test to make sure I understand what's going on:


        def test_filter_is_registered(self):
            """Test that the filter can be used from within a template"""
            
            template = Template(('{% load generic_filters %}'
                                 '{{ pepulator|as_detail_html }}'))
            context = Context({'pepulator':self.m})
            
            expected_detail = (u"Pepulator #123456:[('serial number', 123456),"
                " ('height', 25), ('width', 16), ('manufacture date', %r),"
                " ('color', 'chartreuse')]") % self.m.manufacture_date
            detail = template.render(context)
            
            gf.get_template.assert_called_with('object_detail.html')
            self.assertEqual(detail, expected_detail)

And it turns out all I have to do to satisfy it is change my module in the following way:


    from django.template import Context, Template, Library
    from django.template.loader import get_template

    register = Library()

    @register.filter
    def as_detail_html(instance):
        ...

Now I have a working object detail template. Yay! I figure I'll do the list the same way.

More on Github: https://github.com/mjumbewu/django-model-filters