Django: Auto-generated urlpatterns for existing CBVs
I recently refactored a large set of CBVs in a Django project that all functioned quite similarly to one another and followed a very predictable pattern — in fact they all shared the same template file, and had very nearly identical context dictionary formats. There was a huge opportunity to make it more DRY (Don’t Repeat Yourself).
The solution I came up with was heavily inspired by how the admin
module works in Django — wherein a developer creates a new Admin
class (View) for an object, then “registers” that class using a decorator, and the corresponding urlpattern
entries are automatically generated and managed for them. Each app has its own admin.py
file which contains only the admin entries relevant for that app, allowing for easy organization of admin
classes to stay within the apps they administer.
In this article, I will describe how I built very similar functionality for my Django project.
Background
For this article, we will be creating a new CBV Mixin for use for a specific set of views across all our apps within our Django project: an “add item” wizard. Whenever we create a FormView
CBV for the purpose of adding or editing records of a model, there’s a bunch of boilerplate code that ends up being repeated across all those CBVs. Let’s abstract that repetition into a Mixin we can easily use later on:
from django.views.generic import FormView
class WizardMixin(FormView):
template_name = 'form.html'
def get_context_data(self, **kwargs) -> dict:
context = super().get_context_data(**kwargs)
context['wizard_name'] = self.Meta.wizard_name
context['wizard_description'] = self.Meta.wizard_description
return context
def get_initial(self):
"""
Pre-populate form using any values passed in via HTTP GET
"""
initial = super().get_initial()
initial.update(self.request.GET.items())
return initial
class Meta:
wizard_name = 'Default Name'
wizard_description = 'Fill out the form below.'
Our simple Mixin provides some basic functionality in terms of automatically setting form values based on HTTP GET params, and it automatically generates a few useful context vars to send to the template.
As an example, let’s now create a new file in our produce
app called wixards.py
— here we’ll add our wizards for the various models in our app:
from django.views.generic import CreateView
from .mixins import WizardMixin
from .models import Shape, Fruit
class FruitCreateWizard(WizardMixin, CreateView):
model = Fruit
fields = ['name', 'price', 'description']
class Meta:
wizard_name = 'Add new fruit'
wizard_description = 'Enter the information below about the new fruit to add:'
class ShapeCreateWizard(WizardMixin, CreateView):
model = Shape
fields = ['name', 'num_sides', 'description']
class Meta:
wizard_name = 'Add new shape'
wizard_description = 'This allows creation of a new shape object'
Next, we need to define and manage urlpatterns
for these new CBVs in our app’s urls.py
below:
from .apps.produce.wizards import FruitCreateWizard
from .apps.geometry.wizards import ShapeCreateWizard
urlpatterns = [
# Other URL patterns go here
re_path(r'^wizards/fruit/create/$', FruitCreateWizard.as_view(), name='add_fruit'),
re_path(r'^wizards/shape/create/$', ShapeCreateWizard.as_view(), name='add_shape'),
]
urlpatterns += wizards.get_urls()
Over time, managing urls for all our wizards quickly gets unwieldy, especially as they all follow a very predictable pattern.
By comparison, registering a new AdminView
requires no such updates to urls, for example:
from django.contrib import admin
from .models import Fruit
@admin.register(Fruit)
class FruitAdmin(admin.ModelAdmin):
list_display = ('name', 'price')
search_fields = ['name', 'description']
The goal of this article is to achieve a similar functionality for our WizardMixin
classes.
Step 1: Create the registry
The first step we need to take is to create a class whose purpose it is to keep track of all our Wizard CBVs for us inside a single “registry”. We’ll overwrite the __new__
method in this class to ensure it is only ever instantiated once, as we don’t want multiple unique copies of this registry floating around. We’ll put this in a sites.py
modules we can import later:
class WizardSite:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is not None:
return cls._instance
self = object.__new__(cls, *args, **kwargs)
cls._instance = self
return self
def __init__(self):
self._urls = {}
if globals().get('wizards') is None:
wizards = WizardSite()
Next, we add a register
method to our new class, which we use to add new entries to the class’ ongoing registry:
class NotAWizardException(Exception):
pass
class WizardSite:
# Skipping prior methods from previous snippet.
def register(self, wizard_class, urlpattern, urlname):
if not issubclass(wizard_class, WizardMixin):
raise NotAWizardException(f'Class {wizard_class} is not a wizard!')
if type(wizard_class) != type:
raise NotAWizardException(f'Only classes may be registered. Do not register an instance of a class.')
from django.urls import re_path
self._urls[wizard_class] = re_path(urlpattern, wizard_class.as_view(), name=urlname)
In the register method above, we first employ some Guard clauses to help ensure the wizard_class
parameter is what we expect. After these verifications, we insert our new Wizard subclass to our running list of known wizards, using the class itself as the key, and the urlpattern
as the value.
Importantly, the import statement is inside the method, to avoid problems with imports later.
Finally, we add a method (which we’ll use later) for returning all the URL patterns we’ve registered in a format that Django will recognize:
class WizardSite:
# Skipping methods from previous snippet
def get_urls(self):
return list(self._urls.values())
This method very simply returns a list object will all our registered urlpattern
instances.
Step 2: Create the wrapper/decorator
Next, we’ll want to create an easy-to-use decorator that wraps our CBV wizards to quickly add them to our registry, below:
def register_wizard(urlpattern, name):
def _wizard_class_wrapper(wizard_class):
wizards.register(wizard_class, urlpattern, name)
return wizard_class
return _wizard_class_wrapper
This method is defined outside the WizardSite
class.
With the decorator in place, we can now easily wrap our CBVs in wizards.py
as such:
from django.views.generic import CreateView
from .mixins import WizardMixin
from .sites import register_wizard
from .apps.produce.models import Fruit, Shape
@register_wizard(r'^wizards/fruit/create/$', name='add_fruit')
class FruitCreateWizard(WizardMixin, CreateView):
# Rest of CBV goes here. See "Background" section.
@register_wizard(r'^wizards/shape/create/$', name='add_shape')
class ShapeCreateWizard(WizardMixin, CreateView):
# Rest of CBV goes here. See "Background" section.
Step 3: Add the registry to the URL patterns
Now that we have a dynamic registry of URLs we’re maintaining in a single place, all we need to do is to tell urls.py
about it. This can be simply done by appending to the existing list, using our handy get_urls
method we created previously, for example:
from django.contrib import admin
from django.urls import path, re_path
from .sites import wizards
urlpatterns = [
# Other URL patterns go here
path('admin/', admin.site.urls),
]
urlpatterns += wizards.get_urls()
If you try to run the app in this state, you’ll notice it doesn’t work quite yet! We still have one more step left.
Step 4: Auto-discover modules
You’ll notice that at this point trying to run this app would cause any pages referencing our auto-generated urlpatterns to fail with the following error:
NoReverseMatch at /
Reverse for 'add_fruit' not found. 'add_fruit' is not a valid view function or pattern name.
This is because when the urlpatterns are resolved at Django server startup time, and our registry hasn’t been populated yet!
The last step is to auto-discover all our wizards, in order for the urlpatterns to be registered when the Django server loads up. This can be easily done by leveraging Django’s auto-discover
method, for example:
from django.utils.module_loading import autodiscover_modules
autodiscover_modules('wizards')
urlpatterns = [
# ...
This code assumes all our new wizard CBVs are in modules named wizards.py
. Without this code, the URLs registry would be empty when the Django server is started, because none of the decorators would have been called yet!
With the auto-discover call in place we can now reverse any named urlpatterns we have registered via our new decorator!
Automatically generating patterns and names
We can take this one step further to match how the admin site works, and automatically generate the URL pattern and name for each of our classes. To do this, we can modify our existing register
instance method to make the urlpattern
and urlname
parameters optional, and if no value is passed in, we can use introspection to automatically generate the appropriate urlpattern and name based on the model’s class name:
class WizardSite:
# ...
def register(self, wizard_class, urlpattern=None, urlname=None):
if not issubclass(wizard_class, WizardMixin):
raise NotAWizardException(f'Class {wizard_class} is not a wizard!')
if type(wizard_class) != type:
raise NotAWizardException(f'Only classes may be registered. Do not register an instance of a class.')
from django.urls import re_path
model_name = wizard_class.model.__name__.lower()
urlname = urlname or f'add_{model_name}'
urlpattern = urlpattern or rf'^wizards/{model_name}/create/$'
self._urls[wizard_class] = re_path(urlpattern, wizard_class.as_view(), name=urlname)
The decorator definition would also need its parameters updated to make them optional, as seen below:
def register_wizard(urlpattern=None, name=None):
def _wizard_class_wrapper(wizard_class):
wizards.register(wizard_class, urlpattern, name)
return wizard_class
return _wizard_class_wrapper
With these changes in place, we can now simplify our wizard definitions and drop the two paramters, which now look like this:
from django.views.generic import CreateView
from .mixins import WizardMixin
from .sites import register_wizard
from .apps.produce.models import Fruit, Shape
@register_wizard()
class FruitCreateWizard(WizardMixin, CreateView):
# Rest of CBV goes here
@register_wizard()
class ShapeCreateWizard(WizardMixin, CreateView):
# Rest of CBV goes here
With this change, we’ve now further streamlined the creation of our Wizard CBVs! One drawback of this approach is that it is no longer immediately obvious to the reader what the urlpattern and names are for these CBVs anymore.
That’s it! We now have a working decorator we can use to very quickly and register a new CBV of a certain type, and it will automatically get added to our running list of urlpatterns!
What are your thoughts on this approach? Sound off in the comments below.