Loading…

The hidden powers of Custom Django 2.0 Path Converters

In my last article, I explained the new approach in Django 2.0 to define URL patterns and specifically how to capture URL parameter.

In this article, we will have a closer look at Django path converters, the bit you can optionally put before the colon in a URL parameter definition.

Here are some examples:

path('/posts/<int:pk>/', post_detail)
path('/article/<slug:title>/', article_detail)
path('/job/<uuid:id>/', job_detail)

Path converters have two jobs:

  • they define a regular expression that is used to match a URL
  • they convert between strings and whatever type your view expects

Using the example above, the URL /posts/123/ would lead to the post_detail view being called with the keyword argument pk=123. 123 is an int here, not a str! Vice versa, when reversing a URL, you pass an int.

 >>> reverse('post_detail', (), {'pk': 123})

It is worth noting that the type conversion is part of the URL matching process. If the conversion raises a ValueError, the URL does not match.

This fact could be dismissed as a foot note, but it actually enables exciting new features!

Let’s take the vote view and its corresponding URL definition from Django tutorial. The URL definition looks for a numeric id, which is used by the view to fetch a Question object:

path('<int:question_id>/vote/', views.vote, name='vote'),

def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    ...

You could also implement this with a path resolver:

from django.urls import path, register_converter
from django.urls.converters import IntConverter

from . import views
from .models import Question

class QuestionConverter(IntConverter):

    def to_python(self, value):
        try:
            return Question.objects.get(id=int(value))
        except Question.DoesNotExist:
            raise ValueError

    def to_url(self, obj):
        return str(obj.pk)

register_converter(QuestionConverter, 'question')

path('<question:question>/vote/', views.vote, name='vote'),

def vote(request, question):
    ...

You probably don’t want to write a new converter class for every model you use, so we can write a factory method that dynamically builds the class for us:

class ModelConverter:

    def to_python(self, value):
        try:
            return self.model.objects.get(pk=int(value))
        except self.model.DoesNotExist:
            raise ValueError

    def to_url(self, obj):
        return str(obj.pk)


def register_model_converter(model):
    converter_name = '{}Converter'.format(name.capitalize())
    converter_class = type(converter_name, (ModelConverter,), {'model': model})

    register_converter(converter_class, name)

Now we can you register our question converter like this:

register_model_converter(Question, 'question')

There a few things we can still improve:

- the converter assumes we want to look up the model instance by primary key field (`pk`)
- the converter assumes that the lookup field is an int
- in most cases, the converter is probably going to be named like the model
- we might want to use a custom query set 

Let’s fix this:

class ModelConverterMixin:

    def get_queryset(self):
        if self.queryset:
            return self.queryset.all()
        return self.model.objects.all()

    def to_python(self, value):
        try:
            return self.get_queryset().get(**{self.field: super().to_python(value)})
        except self.model.DoesNotExist:
            raise ValueError

    def to_url(self, obj):
        return super().to_url(getattr(obj, self.field))


def register_model_converter(model, name=None, field='pk', base=IntConverter, queryset=None):
    if name is None:
        name = model.__name__.lower()
    converter_name = '{}Converter'.format(name.capitalize())
    converter_class = type(
        converter_name,
        (ModelConverterMixin, base,),
        {'model': model, 'field': field, 'queryset': queryset}
    )
    
    register_converter(converter_class, name)

This improved version now takes three more arguments:

  • name: name of our converter. If we don’t pass a name, the converter is named like the model, lowercased
  • field: the name of the field we want to use to look up the model instance, defaults to pk
  • base: the base class for our model converter, defaults to IntConverter
  • queryset: the queryset to use, defaults to model.objects.all()

You could copy-and-paste this code in all of your projects, but to save you from that, I’ve polished this code a bit and published it on Github and PyPI.

To install:

$ pip install django-model-path-converter

To use:

from model_path_converter import register_model_converter

This wraps up our little excursion into the depths of Django 2.0 path converters. I always considered the URL matching one of the weakest parts of Django. In the past, I’ve worked with the Agavi framework, which has incredibly powerful URL matching mechanisms (although it is PHP and XML based), so I’m really excited about this new feature.

Custom path converters have some limitations, that I don’t want to pass over. Path converters don’t have access to the current request, so you can’t do things like filter models by the current user. The model path converter I’ve shown you are not compatible with Django’s generic class-based views, at least not without overwriting their get_object() method. They are probably best suited for plain function based views.

What is your opinion? Do you think this is a good idea?

Title photo by Fahrul Azmi

One thought on “The hidden powers of Custom Django 2.0 Path Converters

Leave a Reply