Simple is better than complex. Complex is better than complicated. Flat is better than nested.

The Zen of Python

Redesigning Django's generic class based views

Tagged:
django
python
technical

Introducing django-vanilla-views: a simplified redesign of Django's generic class-based views.

A recent thread on the Django developers mailing list has been discussing adding class hierarchy diagrams to the documentation for generic class based views.

For me, this thread highlighted just how awkward the current implementation is.

Let's take a look at the class hierarchy for one of the views. The following is the hierarchy diagram for CreateView.

CreateView class hierarchy

Yowzers, that's quite something.

It gets even more scary if we take a look at the inheritance diagram for the complete set of generic views. I'm not going to include that here, because it'll hurt your eyes, but there's a link for the brave, here.

The wonderful CCBV site makes working with the views much less painful, but it's still difficult to untangle the behaviour.

As an example, let's take a look through what happens when we make a GET request to CreateView. The calling hierarchy is listed below.

CreateView.get()
|
+-- ProcessFormView.get()
    |
    +-- ModelFormMixin.get_form_class()
    |   |
    |   +-- SingleObjectMixin.get_queryset()
    |
    +-- FormMixin.get_form()
    |   |
    |   +-- ModelFormMixin.get_form_kwargs()
    |   |   |
    |   |   +-- FormMixin.get_form_kwargs()
    |   |
    |   +-- FormMixin.get_initial()
    |
    +-- ModelFormMixin.get_context_data()
    |   |
    |   +-- SingleObjectMixin.get_context_object_name()
    |   |
    |   +-- SingleObjectMixin.get_context_data()
    |       |
    |       +-- SingleObjectMixin.get_context_object_name()
    |       |
    |       +-- ContextMixin.get_context_data()
    |
    +-- TemplateResponseMixin.render_to_response()
        |
        +-- SingleObjectTemplateResponseMixin.get_template_names()
        |
        +-- TemplateResponseMixin.get_template_names()

The logic spans 8 classes, and 3 source files.

<sadface>.

Here's Adrian Holovaty, co-creator of Django, commenting on the current state of affairs.

I'd suggest not using class-based views. They're way over-abstracted and not worth the cognitive burden.

Again, <sadface>.

It doesn't have to be this way.

The Zen of Python says:

Simple is better than complex. Complex is better than complicated. Flat is better than nested.

I'm a big fan of class based views. We use Django's GCBVs extensively in our work at DabApps, and they save a lot of time and code. I don't enjoy the mixin style implementation, but they do what they need to and I wouldn't want to be without them.

Django REST framework also uses a derivative of generic class based views, where they're indespensible to super-fast API development with very little code. In REST framework, we've dropped the Django base implementations entirely in favor of a slightly more simplified version. There's only a single generic base class, and various parts of the API and implementation have been slimmed down. I find them vastly more pleasant to work with, and they're easy to manage from a maintenance perspective - questions on the mailing list don't tend to focus on usage of the generic views, and those that do are easily answered.

I've been meaning to take a similar approach to Django's standard GCBVs, and see if they could be redesigned with a nicer API and more obvious implementation.

Here's the result: django-vanilla-views.

The django-vanilla-views package provides essentially the same set of functionality as Django's existing GCBVs, but with:

  • No mixin classes.
  • No calls to super().
  • A sane class hierarchy.
  • A stripped down API.
  • Simpler method implementations, with less magical behavior.

The date-based generic views haven't yet been implemented, but all the existing model and base generic views are replicated.

The best way to get an idea of how django-vanilla-views differs from the current GCBV implementation is to take a look at its complete view hierarchy.

View --+------------------------- RedirectView
       |
       +-- GenericView -------+-- TemplateView
       |                      |
       |                      +-- FormView
       |
       +-- GenericModelView --+-- ListView
                              |
                              +-- DetailView
                              |
                              +-- CreateView
                              |
                              +-- UpdateView
                              |
                              +-- DeleteView

The django-vanilla-views package also weighs in with substantially less lines of code than the existing implementation. Around 50% for the same set of functionality, compared against Django's existing implementation:

Existing GCBVs

Source file# lines of code
base.py (excluding View class)139
detail.py171
edit.py280
list.py197
total787

Vanilla GCBVs

Source file# lines of code
generic_view.py90
generic_model_view.py300
total390

Compare and contrast.

We've seen the inheritance hierarchy for the current implementation of CreateView, so let's see how the CreateView from django-vanilla-views looks by comparison.

CreateView --> GenericModelView --> View

Let's take a look at the vanilla-style calling hierarchy.

CreateView.get()
|
+-- GenericModelView.get_form()
|   |
|   +-- GenericModelView.get_form_class()
|
+-- GenericModelView.get_context()
|
+-- GenericModelView.get_response()
    |
    +-- GenericModelView.get_template_names()

The difference is dramatic.


Less magic please

The method implementations in Django's current GCBV implementations contain a fair amount of implicit behavior, with more explicit cases gradually falling back to implicit shortcuts.

1. Determining the form class.

  • If .form_class exists, use that.
  • Else if .model exists, create a ModelForm class based on the model class.
  • Else if .object has been set, create a ModelForm class based on the model instance.
  • Else call .get_queryset(), and create a ModelForm class based on the queryset.

2. Determining the template name.

  • If .template_name exists add that.
  • If .object has been set, and .template_name_field exists, determine a template name based on a field on the instance, and add that.
  • If .object has been set, add a template name based on the model class of the instance.
  • Else if .model has been set, add a template name based on the model class.

3. Object lookup.

  • If a queryset argument is passed to .get_object() use that as the base queryset.
  • Else use a base queryset by calling .get_queryset().
  • If .pk_url_kwarg is provided in the view keyword arguments, use that as the object lookup against the primary key.
  • Else if .slug_url_kwarg is provided in the view keyword arguments, use that as the object lookup, looking up against the model field as determined by .get_slug_field(), which defaults to returning .slug_field.
  • Otherwise raise a configuration error.

4. Pagination.

  • Parent calls paginate_by() to determine the page size.
  • Parent calls paginate_queryset() passing the queryset and page size. The method returns a 4-tuple of (paginator, page, queryset, is_paginated).

The vanilla style

In django-vanilla-views we use broadly the same set of behavior, but simplify the implementations where possible. The end result is that the behavior is easier to understand and easier to override.

1. Determining the form class.

  • If .form_class exists, use that.
  • Else if .model exists, create a ModelForm class based on the model class.
  • Otherwise raise a configuration error.

2. Determining the template name.

  • If .template_name exists use that.
  • Else if .model exists, use a template name based on the model class.
  • Otherwise raise a configuration error.

3. Object lookup.

  • Use a base queryset by calling .get_queryset().
  • If .lookup_field is provided in the view keyword arguments, use that as the object lookup. The default value for .lookup_field is 'pk'.
  • Otherwise raise a configuration error.

4. Pagination.

  • Parent calls .paginate_queryset(), passing the queryset. The method returns a page object, or None if pagination is turned off.

Less API please

The generic view APIs have also been slimmed down slightly. The following view attributes that have been removed or replaced.

Object lookup arguments

  • pk_url_kwarg = 'pk'
  • slug_url_kwarg = 'slug'
  • slug_field = 'slug'

Replaced with:

  • lookup_field = 'pk'

If you need slug based lookup, just set the attribute.

class MyView(DetailView):
    model = MyModel
    lookup_field = 'slug'

Template name arguments

  • template_name_field = None

Removed. If you need a view that returns template names based on a field on the object instance, just write some code.

def get_template_names(self):
    return [self.object.template_name]

Pagination arguments

  • allow_empty = True

Removed. If you need a view that returns 404 responses for empty querysets, just write some code.

def get_queryset(self):
    queryset = super(MyView, self).get_queryset()
    if queryset.empty():
        raise Http404
    return queryset

Response arguments

  • content_type = None
  • response_cls = TemplateResponse

Removed. If you need a view that returns a different style of response, just write some code.

def get_response(context):
    # Note: Would typically need something a more complex than `json.dumps`
    # in order to handle serializing model instances and querysets.
    data = json.dumps(context)
    return HttpResponse(data, content_type='application/json')

Aside: Building better API behavior directly into the class based views is a topic for another day's discussion, but it's worth noting that you might want to consider Django REST framework if you're building APIs with class based views.


Let's make things better

It's okay that we as a community ended up with an awkward design for the generic class based views. The mixin design seemed like a good idea at the time, and no-one came up with a cleaner implementation. They fulfil their purpose, and it's a testment to the fundamental soundness of the concept that they are now so widely used. However, we do need to recognize that the current implementation and API is unacceptably complex, and figure out how we can improve the situation.

The current django-vanilla-views package is a first pass. It still needs documentation, tests, and perhaps also the date generic views. Any real proposal to modifying Django would also need to take into account a deprecation policy or migration guide. From my point of view django-vanilla-views seems like a huge improvement, and I'd really like community feedback on if and how we could go about getting something like this into core.

If you found this article and the django-vanilla-views package interesting, you might want to follow Tom Christie on Twitter, here, and DabApps here

Need a top-class engineering team? DabApps offers Web, API and Mobile development using Django and other technologies.

You might also be interested in our Python for Programmers training course, running in October and November this year.

To find out more, get in touch.

blog comments powered by Disqus