Photo by Michael on Unsplash

Django: Auto-generated urlpatterns for existing CBVs

Adrien Van Thong

--

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.

--

--