Wednesday, January 5, 2011

Rendering a django queryset as a html table (like form.as_table) using a custom template filter

Django has some nice helpers for forms (forms.as_p and forms.as_table) that you can use in your template to minimise boilerplate HTML. Unfortunately, despite some discussion/confusion, and at least one project, there doesn't appear to be a similar elegant solution for querysets.

My approach to the problem was to implement a custom template filter you can use in a template like this (where object_list is your queryset):
{{ object_list|result_as_table }}
i.e. you hand it a queryset, and it gives you back a table.

Here is the code for the filter. I'm using this for a number of subclasses of a class called resultdata, so that they can share an identical template and just provide different querysets in urls.py.

def result_as_table(queryset, fieldnames=None):
    """Take a resultdata queryset and return it as a HTML table.
    Columns will be returned in the order they are declared in the model.
    
    Optionally, provide a list of fieldnames, which will be used to limit the output."""


    dictlist = queryset.values()
    output = "<table>\n"
    output_keys = []

    if fieldnames:
        names = fieldnames
    else:
        names = dictlist.field_names

    for name in names:

        if not name.endswith("_id"):
            output_keys.append(name)
            output = "".join( (output, "<th>%s</th>\n" % escape(name.replace("_"," ").capitalize())) )

    for rddict in dictlist:

        output = "".join( (output, "<tr>\n") )

        for key in output_keys:
            val = rddict[key]
            if not key.endswith("_id"):

                display_func = get_display_method(rddict, queryset, key)

                if display_func:
                    output = "".join( (output, "<td>%s</td>\n" % escape(display_func())) )
                else:
                    output = "".join( (output, "<td>%s</td>\n" % escape(val)) )

        output = "".join( (output, "</tr>\n") )

    return mark_safe("".join( (output, "</table>\n") ))

result_as_table.is_safe = True

This filter introspects the column headers, strips out any id fields (since users don't care about them), converts underscores to spaces in column headers, and finds the corresponding display methods for any 'choices' fields so you can still get the nice human-readable output. It outputs HTML, so it uses django's own 'django.utils.html.escape' method and marks the output as safe so django doesn't escape all the HTML.

Finding the display methods was a little harder than I expected, since the python inspect code was dying when given a django model object. This code is unfortunately tied to the resultdata object due to the naming of the keys in the values() dictionary, making it less generic than I would like.

def get_display_method(rddict, queryset, key):
    """Re-implementation of inspect.getmembers(rddict,inspect.ismethod) and 
    a test to see if this is a get_field_status method.  Had to reimplement inspect
    because it was bombing on the object - expecting some property that is not present."""

    rdobj = queryset[0].__class__.objects.filter(resultdata_id = rddict["resultdata_id"]).get()
    targetname = "get_%s_display" % key
    display_func = False

    for name in dir(rdobj):
        if name == targetname:
            try:
                display_func = getattr(rdobj, name)
                break
            except Exception,e:
                pass

    return display_func

There was a major gotcha with using the field_names property of the queryset.values() dictionary. If you passed a queryset with annotations like this is your urls.py:

list_rd = {
    'extra_context': {'fieldnames': ['status','first','total']},
    'queryset': ResultData.objects.annotate(total = Count('link__clickback'), 
                                            first = Min('link__clickback__timestamp')),
}

urlpatterns += patterns('project.app.views',
    url(r'^result/detail/$', 'detailed_results', kwargs=list_rd, name='detailed_results'),
)


The annotations would not get added into the field_names property. This was solved by providing the optional fieldnames parameter.

4 comments:

GamesBook said...

Hi - this looks to be very useful while prototyping ... but when I try and use it, then I get this error:

KeyError at /query/site/
'resultdata_id'

at this line:

rdobj = queryset[0].__class__.objects.filter(resultdata_id=rddict["resultdata_id"]).get()

Please can you explain further about the use of the "resultdata" object, given that the template is receiving a queryset and not an object as such?

G said...

It's a queryset of resultdata objects. You'll need to change the code to operate on whatever object is in your queryset. Hence why I say 'This code is unfortunately tied to the resultdata object'.

GamesBook said...

On the assumption that my queryset would likely contain an id field, I replaced the filter part in the robj assignment with:

filter(id=rddict.get("id"))

and that seemed to work.

Anonymous said...

Thanks for this posting, it was helpful in aiding me to solve a similar problem.

--
Mick T.