Yourlabs

Rethinking Django’s URL Router

| by jpic | django python crudlfap

This all started when a web developer learning Django was struggling to maintain a quantity of HTML and came to me: “I have a question for you, Django, can you generate a menu for me please ?".

Oh my, is this person going to be the next wasting their time building something that’s too complicated and worthless just like I have back when I used to try this kind of stunt ?

But still, that’s a good question, why can’t Django generate a CRUD with permissions and menus in 2017 ? I’m not talking about the admin of course, the admin is better than PhpMyAdmin for users yes, but it’s also something that was designed even before class based views and that you end up struggling against when trying to do too much with it so I don’t consider the admin as something you could present as frontend.

We’ll hop in the train to 2017, but first, let’s see if that seems familiar to you ?

    urlpatterns = [
      url('mymodel/(?P<pk>\d+)/update/$', UpdateView.as_view(model=Model), name='model_update'),
      url('mymodel/(?P<pk>\d+)/delete/$', DeleteView.as_view(model=Model), name='model_delete'),
      url('mymodel/(?P<pk>\d+)/$', DetailView.as_view(model=Model), name='model_detail'),
      url('mymodel/(?P<pk>\d+)/$', ListView.as_view(model=Model), name='model_list'),
      url('mymodel/create/$', CreateView.as_view(model=Model), name='model_create'),
      
      # Copy/paste for every model and let human mistakes start a show
    ]

Why all this code for a CRUD ? Another way would be to use a Router object that would be composed of your default CRUD views:

    urlpatterns = Router(MyModel, url_prefix='mymodel/').urlpatterns()

Of course, we need better default views (using messages for user feedback), which would have default templates too, then this example would make up working pages.

It’s the job of the Router to choose the default view list. But it should be roughly equivalent to this:

    urlpatterns = [
      UpdateView.factory(model=MyModel, url_prefix='mymodel/').url(),
      DeleteView.factory(model=MyModel, url_prefix='mymodel/').url(),
      DetailView.factory(model=MyModel, url_prefix='mymodel/').url(),
      CreateView.factory(model=MyModel, url_prefix='mymodel/').url(),
      ListView.factory(model=MyModel, url_prefix='mymodel/').url(),
    ]

Except that you can also do this to get a view class:

    Router['myapp.mymodel']['list']

The call to factory in the other example above uses type() to generate a subclass on the fly with the given attributes, so, url() calls as_view() behind the scenes without argument. Since views can generate their urls here, it turns out we can do this:

    urlpatterns = [
      UpdateView.factory(
        model=MyModel, 
        form=SuperForm,
        url_name='mymodel_super_update',
        url_prefix='mymodel/',
        url_pattern='some/weird/(?P<stuff>.*)/$',
      ).url()
    ]

Ok that’s all very nice but what about non standard CRUDL views that you would be adding ? Well, mainly the view needs to know it’s model and slug to generate the best url configuration out of the box. The slug, in “UpdateView” for example, is calculated to “update”, for the “ArtistUpdateView” you have that has model=Artist then again, the slug would be “update”. The view slug must be unique within a Router. It’s up to the view then to decide how it’s going to use it to generate the url pattern. Don’t worry if you don’t quite get it all nicely in your head the first time, just trust unit tests which demonstrates each of these variables could be generated by default and overridden with a type() call or for instance with .factory().

Now, let’s add a custom view:

    urlpatterns = Router(
      MyModel,
      url_prefix='mymodel/',
      views=[
        'yourframework.UpdateView',
        'yourframework.DeleteView',
        'yourframework.DetailView',
        'yourcustom.DeployView',
        'yourframework.CreateView',
        'yourframework.ListView',
        YourOtherView.factory(
          url_pattern='some/(?P<crazy>.*/pattern/$',
          url_name='other',
        ),
      ]
    )

If your DeployView inherits from ObjectViewMixin, then it will figure its short name deploy make an object url pattern such as with the slug or pk by default, prefixed with /deploy/, the view’s short name:

    url(
        'mymodel/(?P<slug>[\w\d_-]+)/deploy/$',
        DeployView.as_view(model=MyModel),
        'mymodel_deploy',
    ),
    url(
        'some/(?P<crazy>.*/pattern/$',
        YourOtherView.as_view(),
        'mymodel_other',
    )

So, what about our menu ? Well, each view has a simple “menus” attribute. For example:

  • CreateView, ListView have menus=['model'],
  • DetailView, DeleteView, UpdateView have menus=['object'],

So, in the detail template for example I need the object menu (yes, this is Jinja2, because remember, you took the train to 2017):

    {% for v in Router.get_menu('object') %}
      {% set v=v.factory(object=object, request=request)() %}
      {# we passed request above because allow() below wants self.request.user #}
      {% if v != view and view.allow() %}
        {# 
        above we check that it's not the same as the current 
        view and that the user has permission in the same if
        #}
        <a href="%7B%7B%20view.reverse%20%7D%7D" data-target="{{ view.target }}" data-ajax="{{ view.ajax }}" title="{{ view.get_title() }}"><i class="material-icon material-{{ view.material_icon }}"></i></a>
      {% endif %}
    {% endfor %}

If that doesn’t blow your mind then I’m sorry that I wasted your time, otherwise feel free to keep on reading the a clean and tested implementation in the CRUDLFA+ repository, namely in src/crudlfap/routers.py and src/crudlfap/views/routable.py.

Then you can just do permission like this:

    class ExampleView(View):
        def allow(self):
            if not self.request.user.is_authenticated():
                return False
            if self.object.pk == 1:
                return True
            return self.request.user.is_staff

And, magic, you can check them in Jinja2 templates as demonstrated above:

    artist = Artist(pk=1)
    request = rf.get('/')
    request.user = AnonymousUser()

    view = ExampleView.factory(object=artist, request=request)()
    assert view.allow() == False, 'forbid anonymous'

    request.user = User(is_staff=False)
    view = ExampleView.factory(object=artist, request=request)()
    assert view.allow() == True, 'allow pk=1 for authed users'

    artist.pk = 12
    view = ExampleView.factory(object=artist, request=request)()
    assert view.allow() == False, 'forbid pk=12 for non staff'

    request.user = User(is_staff=True)
    view = ExampleView.factory(object=artist, request=request)()
    assert view.allow() == True, 'allow any pk for staff'

I would like to thank Django contributors for making this so easy because Django has such a beautiful design, DevNix for partly sponsoring this research, Etienne Vidal for incepting some concepts about what a web framework should do out of the box in 2017.

With <3

Jamesy aka jpic