DEV Community

Cover image for How to solve the singleton problem in Django ModelAdmin.
Maxim Danilov
Maxim Danilov

Posted on

How to solve the singleton problem in Django ModelAdmin.

The ModelAdmin class from django.contrib.admin.options has a bad design from the beginning: every registered ModelAdmin in your project is a singleton.

This fact creates a big barrier for every developer who uses Django Admin in his project. This slows down the project, creates problems when the same ModelAdmin is used by several users, and encourages programmers to use hacks to get around the limitations of this (anti) pattern. You can learn how to solve this problem in 5 lines in this article.

Why the developers of django.contrib.admin continue to rely on this architecture is a mystery to me.

Luckily there has been a quick and easy fix for this issue that works from the first version of Django up to the current one (4.0.3 in april 2022).

Table of Content:

  • The problems. I will describe in detail the problems encountered when using the singleton architecture for ModelAdmin.
  • The solution. I will walk you through the fix and explain how and why it works.
  • Django devs. I'm asking Django developers reading this article to consider changing the django.contrib.admin structure to make life easier for everyone.
  • Talk from PyCon 2022 about this problem. In addition to this article I have a video about this topic from PyCon DE 2022 in Berlin.

The problems

1. Multiple users can not work normally with the same ModelAdmin at the same time.

When multiple users are running the admin panel at the same time, the singleton design pattern can allow one user to modify another user's data.

Let me demonstrate it with the code from my previous article about Django admin dynamic inline positioning. Right now it is not really important what the code does. We just modify the functions in the admin panel like this:

Now let's open the two tabs of the admin panel to simulate two users. Now let's open the change form for the product with pk=1 on the first tab (/admin/products/product/1/change). After this line, we are stopped by the breakpoint:

10            self.hello = 'hello this is the first obj'
Enter fullscreen mode Exit fullscreen mode

If we check the self.hello attribute, we see the following:

Attribute 'hello' of ModelAdmin by GET request, product id=1

Now let's open a change form for a product with pk=2 in another tab ('/admin/products/product/2/change') and see what happens. We get a response from the change form right away because we skipped the breakpoint. But let's see what happened to our hello attribute that we set for our ModelAdmin in the first tab.

Attribute 'hello' in ModelAdmin for GET request pk=1 changed by other request

Our ModelAdmin.hello attribute for GET request pk=1 has been changed to "this is the second obj", even though we did nothing in our first thread.

We proved that ModelAdmin is a singleton, which means that we always get the same instance of ModelAdmin.

This is a simple demonstration of the problem. Since there are no real consequences in this example, some might even consider it trivial, but the consequences of this behavior for our project could be dangerous.

With this behavior, every ModelAdmin's do not guarantee the integrity of the data, because it is impossible to trace who has edited the data. Did this current thread edit our data? Did another request change something without our knowledge? Who knows.

You might argue that this isn't a problem because the requests have to happen at the same time for this problem to take effect. However, in a project where multiple people are running the admin panel at the same time, the issue is not if someone opens ModelAdmin at the same time as someone else, but when.

This causes errors, for which there is no obvious explanation without an in-depth understanding of the admin panel architecture.

So the easiest way to avoid the problem is not to use the ModelAdmin instance as a container.

But not every developer is aware of this, and ModelAdmin is often used by inexperienced developers to store data.
In this example the highest voted answer does exactly that.

If we can't use ModelAdmin, then where can we store our data? Django does not give us a clear answer by default.

2. Hacks

Since you can't use an instance of ModelAdmin as a container, developers are looking for other options.

Most of the workarounds I've found are to store the information in a global dictionary and map it to the locals of current thread. Yes, that works, but shouldn't there be another way than that?

3. Speed

If it were possible to use ModelAdmin as a container for the data, Django itself could do it. Currently Django repeatedly calls certain ModelAdmin methods to get the same result during the same request. All of these results should be cached. If ModelAdmin behaved like Django-GCBV, we could save valuable computation time.

The table shows how often the following ModelAdmin methods are called several times per request:

changelist GET change GET change POST add GET add POST delete
has_view_permission 5 3 4 3
has_module_permission 3 3 3
get_ordering 2 3 3 4 3
get_preserved_filters 2 2 2 2
has_change_permission 9 4 4 4
get_list_display 2
get_search_fields 2
get_model_perms 3 3 3
has_delete_permission 4 3 4 3 3
get_readonly_fields 2 2
get_search_results
has_add_permission 4 3 6 3
get_empty_value_display variable variable variable
get_actions 3
_get_base_actions

The number of calls to some methods depends on the number of AdminForm or ModelForm fields.

Let's compare the response time of the "vanilla" Django admin panel by default and the Django admin panel with cached method of ModelAdmin. As a code example I took the project from my last article:

I have created a middleware that outputs the time elapsed between the request and the response:

If the middleware code is executed, we will get the result:

default django admin panel modified admin panel
Time 1 default django admin panel Time 1 cached solution
Time 2 default django admin panel Time 2 cached solution
Time 3 default django admin panel Time 3 cached solution
average time: 0.031673s average time: 0.026001s

As you can see, even in this simple project the speed increase, due to caching the results of calculating functions, is **almost 20%!

This speed can be greatly increased by refactoring the entire ModelAdmin class, since it is currently a bloated piece of spagetty-code.

The solution

Let's now take a look at where ModelAdmin singletons come from in Django and try to fix it.

Each instance of ModelAdmin is created when it is registered by instance of AdminSite from django.contrib.admin.sites.

In the docstring of the AdminSite class, we learn that the get_urls method is used to retrieve views from each registered instance of ModelAdmin. Thanks to the author of this docstring.

In the AdminSite.get_urls we can find this code snipped:

Here we see that the ModelAdmin.urls property is called.
This property returns the result of the ModelAdmin.get_urls method. So, let's explore what this method get_urls does:

At first glance it seems like a lot of code, but let's unpack it piece by piece. Let's start exploring this code from the bottom.

The returned list is created for url-dispatcher. It contains tuples from the url, the view to be called, and the name of the view.

What matters to us is, what is given as a view. This is a wrapped bounded method of a ModelAdmin instance. So, let's take a look at what this wrapper wrap does.

Fortunately, this wrapper wrap is defined right above.
The update_wrapper at the end of wrap is not very important, it just makes the final view behave like a ModelAdmin instance.

The body of the wrapper function is crucial for our mission. There we see a call of admin_site.admin_view with bounded method of ModelAdmin instance as an argument.
admin_site is the instance of AdminSite on which the ModelAdmin instance is registered.

This method we need to override to rid Django ModelAdmin of the curse of the singleton pattern.

Pffew, all these explanations are for one simple function that needs to be overridden. Yes, because without knowing how this whole system works, we wouldn't understand how to properly override this function to solve our problem and not break anything.

Let's create a child class of AdminSite and override the admin_view method like this:

Let's ignore all the wrappers and focus on these lines of code:

new_instance = type(instance)(instance.model, instance.admin_site)
return func.__func__(new_instance, *args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

func is a bounded method of our singleton instance of ModelAdmin, which is always the same for every query.

The __func__ attribute of func is a method of the ModelAdmin class. It is not more associated with any instance of ModelAdmin.

Here I am using python terminology bound and unbound method. func.__func__ is not bound to an instance of func.__self__, but it is also not a static function, it is a class method. An instance of the same class as func.__self__ is still required as the first argument to call this method.

To create a new instance of ModelAdmin we need model and admin_site as arguments, which can easily be taken from the old singleton-instance.

Now we can return 'func' call with new instance as self, and arguments *args and **kwargs, that are passed at the time of the call.

Now the Singleton architecture is successfully bypassed.

This means that every time a request is sent to the view a new ModelAdmin instance is created and it's corresponding view function is called

As mentioned earlier, it was possible to avoid all this agony from the beginning of Django.

All that remains is to make our child AdminSite as default in our project.

We can create a child of the AdminConfig class like this:

The default_site attribute must match your AdminSite class.

Finally, register AdminConfig in the settings.py in INSTALLED_APPS to complete the changes. Remember to remove the default admin panel.

This new admin panel in its current form is not faster than the old one. We still need to cache ModelAdmin methods. To do this, you can create a Mixin for ModelAdmin, and wrap the above methods as follows:

I haven't had any problems with this caching implementation, but if you're worried about multiple calls to the same method with different arguments, you can wrap the methods with memoize wrapper. This can also reduce the speedboost of our solution.

Conclusion

I love the Django Framework. It's a powerful tool that helps me create great things. I can fix any problem of this framework with just a small piece of code in the right place. But finding that place is not always easy. And Django's documentation can't help. This lack of documentation - This is Django's biggest problem.

Django devs

I hope that after the points I have laid out in this article, you will agree that there are serious problems with Django's implementation of the singleton architecture in the admin panel.
This pattern makes the admin panel experience cumbersome and slow, and something needs to be done to fix it. And I'm writing here how that can be done.

The fix I've proposed is an important first step to improving the Django admin panel, but unfortunately the next work begins after its implementation. The ModelAdmin class code needs to be greatly improved, i ask about it, for example, in this issue, but if it can be done, I'm sure the Django admin panel will be a better tool to work with.

The Singleton Problem talk at PyCon 2022

I have spoken about this problem at many events. You can see my video from PyCon DE in Berlin in April 2022, I wrote here that I will be speaking at that conference.

Discussion (0)