Maxim Danilov
Posted on May 3, 2022
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'
If we check the self.hello
attribute, we see the following:
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.
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 |
---|---|
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)
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 offunc.__self__
, but it is also not a static function, it is a class method. An instance of the same class asfunc.__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.
Posted on May 3, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.