Using class-based views effectively

Edit:

This post was written before class based generic views landed in django. It discusses encapsulating common view functionality in a class. An example is the way the django admin site works. If you’re looking for information on django’s new class-based generic views, check out this excellent post.

Original post:

Object-oriented programming stresses the idea of code reuse, through concepts like inheritance and polymorphism. View programming in django can sometimes get a boost from class-based design. The thing to stress, though, is reuse. Not all views need to be reusable – a one-off weblog’s list and detail views can be written very concisely and wrapping them in a class may not save you any time down the road. Additionally, Django already ships with generic views and there are other tools to make view writing less repetitive.

What I want to discuss is when a class based view can really help you. One of the best-known features in Django uses class-based views to create easily customizable CRUD views for any kind of model — the admin site. To add the admin to your site, all you need to do is include it in your urlconf at some point:

syntax:python
from django.conf.urls.defaults import *
from django.contrib import admin

admin.autodiscover()

urlpatterns = patterns('',
    (r'^admin/', include(admin.site.urls)),
)

So what is admin.site.urls? It’s actually a property that maps to a method (get_urls) on the AdminSite object. The AdminSite is instantiated and imported into the bottom of the admin’s init.py, so it’s conveniently importable. Looking inside get_urls() on the AdminSite object, we see that wrapped up in the class is a function that returns urlpatterns:

syntax:python
def get_urls(self):
    from django.conf.urls.defaults import patterns, url, include

    def wrap(view, cacheable=False):
        def wrapper(*args, **kwargs):
            return self.admin_view(view, cacheable)(*args, **kwargs)
        return update_wrapper(wrapper, view)

    # Admin-site-wide views.
    urlpatterns = patterns('',
        url(r'^$',
            wrap(self.index),
            name='index'),
        url(r'^logout/$',
            wrap(self.logout),
            name='logout'),
        url(r'^password_change/$',
            wrap(self.password_change, cacheable=True),
            name='password_change'),
        url(r'^password_change/done/$',
            wrap(self.password_change_done, cacheable=True),
            name='password_change_done'),
        url(r'^jsi18n/$',
            wrap(self.i18n_javascript, cacheable=True),
            name='jsi18n'),
        url(r'^r/(?P<content_type_id>\d+)/(?P<object_id>.+)/$',
            'django.views.defaults.shortcut'),
        url(r'^(?P<app_label>\w+)/$',
            wrap(self.app_index),
            name='app_list')
    )

Some cool stuff is going on in here, which I’d like to point out. One, is that the AdminSite is directing its urlpatterns to views that are actually methods on the class itself. It wraps each view with a decorator (see admin_view()) that ensures the requesting user has permissions to access each view. Each method is defined on the class, like app_index() or what-have-you, and these are simply functions that take a request and return an HttpResponse, just like any other view.

The most powerful part of the get_urls() method is on line 223:

syntax:python
    # Add in each model's views.
    for model, model_admin in self._registry.iteritems():
        urlpatterns += patterns('',
            url(r'^%s/%s/' % (model._meta.app_label, model._meta.module_name),
                include(model_admin.urls))
        )
    return urlpatterns

As the comment illustrates, the AdminSite instance iterates over all the apps that have been registered with it, and creates url patterns dynamically for each app. Furthermore, it includes yet another object with the following bit: include(model_admin.urls)

On the ModelAdmin class, there appears again the pattern of defining a get_urls() method which maps to a urls property. Again, we see that the views are wrapped in a decorator, which is incidentally the decorator from earlier which is responsible for making sure the requesting user has permission to access and modify the particular object. The url patterns all point to views which are methods on the ModelAdmin class, much the same as the AdminSite url patterns pointed to methods on itself. In this way, a generic set of views is instantly available to all models which register with the AdminSite.

Digging into the add_view(), we see that at its core it’s really nothing more than a typical form handling view. If the user POSTs to it, it saves a new model, if its a GET request, it simply renders the forms. This is a huge oversimplification, but it illustrates the point that in essence, these are the same kinds of views we’re used to seeing, just wrapped up in a very reusable way. There’s a multitude of convenience methods on the ModelAdmin itself that allow it to be generic, such as the get_form() method which builds a form for each model dynamically.

What I want to stress is that these views are extremely reusable, extremely customizable, and are not hard to implement yourself. You might not even think of them as class based views, but as a normal class that knows how to handle requests and responses, in addition to doing other things. One of the patterns I really like is to wrap all my class-based views in a decorator. That decorator can read values from the url regex, assign values to the View instance (I capitalize it since it’s a class) and do it all in once place. This is handy if you have a set of views that are all doing roughly the same sort of initialization each go-round.

So here’s an example – all it does is show a list of comments for a model, but it uses some of the techniques described above. It defines a get_urls() / urls internally, pointing at views which are methods on the class. In this case there’s only one view, which returns an object_list of Comment objects. The decorator refers to the get_object() method, which is responsible for extracting an object using the args and kwargs. There’s also a convenience method get_template_name().

syntax:python
import os
from django.contrib.comments.models import Comment
from django.contrib.contenttypes.models import ContentType
from django.views.generic.list_detail import object_list

class CommentView(object):
    model = None

    def __init__(self, template_dir):
        self.template_dir = template_dir
        self.content_type = ContentType.objects.get_for_model(self.model)

    def _comment_view(self, view):
        """
        A view decorator which ensures we are getting an object with each
        request and assigning it to the class so everything can access it
        """
        def wrapper(request, *args, **kwargs):
            obj = self.get_object(request, *args, **kwargs)
            return view(request, obj, *args, **kwargs)
        return wrapper

    def get_urls(self):
        from django.conf.urls.defaults import patterns, url

        # Comments views
        urlpatterns = patterns('',
            url(r'^$',
                self._comment_view(self.list_view),
                name='comment_list_view')
        )
        return urlpatterns
    urls = property(get_urls)

    def list_view(self, request, obj, template_name='comment_list.html', 
                  *args, **kwargs):
        """
        Return a list of comments for an object
        """
        comment_qs = Comment.objects.filter(
            content_type=self.content_type,
            object_pk=str(obj.pk))
        list_template = self.get_template_name(template_name)
        return object_list(
            request,
            queryset=comment_qs,
            template_name=list_template,
            extra_context={'object': obj}
        )

    def get_object(self, request, *args, **kwargs):
        """
        Subclasses must implement this method, which tells the class how
        to retrieve an object from the args & kwargs passed in
        """
        raise NotImplementedError

    def get_queryset(self):
        return self.model._default_manager.all()

    def get_template_name(self, template_name):
        return os.path.join(self.template_dir, template_name)

Note that in the above example, no model attribute was defined on the class. Here comes the re-use part: each model-class that wishes to use these comment-y views will subclass CommentView, implementing a get_object() method, and optionally any other methods. So to add CommentViews to a “Post” type model, this is what I would do:

syntax:python
from django.conf.urls.defaults import *
from django.shortcuts import get_object_or_404
from posts.models import Post
from some_comments_app.views import CommentView

# subclass the CommentView and implement get_object()
class PostCommentView(CommentView):
    model = Post

    def get_object(self, request, post_slug=None, *args, **kwargs):
        return get_object_or_404(self.get_queryset(), slug=post_slug)

# instantiate the class
post_comment_view = PostCommentView(template_dir='posts')

urlpatterns = patterns('',
    url(r'^$', 'posts.views.post_index', name='post_index'),
    url(r'^(?P<post_slug>[\w-]+)/$', 'posts.views.post_detail', name='post_detail'),
    url(r'^(?P<post_slug>[\w-]+)/comments/', include(post_comment_view.urls))
)

The one thing I’ve run into with class-based views not working so well is when it comes to reversing URLs. Say I want to reverse a URL to point at a comment list on a post. I’ll have to know the post slug to make it work. Also, if I had the app living on two apps that took slugs, and the class-based views had the same name (in the above case, ‘comment_list_view’), there would be some trouble reversing since it would match both patterns. I’ve worked around this using a prefix and just relying on the reversing to happen in such a way that the correct kwargs will always be supplied, but this is a pain point.

There was a cool talk about reusable apps in Django, the result of which is django-pluggables. The writers are billing it as a design pattern, but my personal feeling is that its more of a hack to make something work within the bounds of Django’s url routing system. Anyways, its definitely worth checking out as there are some cool ideas at work there.

Lastly, check out Cody Soyland’s post on writing thread-safe class-based views. None of the views covered here are prone to the problems Cody talks about, other than the obvious one of state being persisted across requests if you have a single class instance handling your views, but that can be a win depending on what you want to do! As always, I welcome feedback (and feel free to let me know if you’ve got a good way of handling url reversing!).

Read full article at “charlesleifer.com: Entries tagged with "django"”

Leave a comment