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, lowercasedfield
: the name of the field we want to use to look up the model instance, defaults topk
base
: the base class for our model converter, defaults toIntConverter
queryset
: the queryset to use, defaults tomodel.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”