How to solve the singleton problem in Django ModelAdmin.
Tags: django, python
Published: 2022-05-03 16:20:29

 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](#the-problems).** I will describe in detail the problems encountered when using the singleton architecture for ModelAdmin.
- **[The solution](#the-solution).** I will walk you through the fix and explain how and why it works. 
- **[Django devs](#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](#the-singleton-problem-talk-at-pycon-2022).** 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](https://dev.to/danilovmy/django-admin-dynamic-inline-positioning-3cam). Right now it is not really important what the code does. We just modify the functions in the admin panel like this:

{% gist https://gist.github.com/danilovmy/85cb573bc490e273e4869726363c8ec6 %}

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:

![Attribute 'hello' of ModelAdmin by GET request, product id=1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4fa3ggsdhsx8dwalksue.JPG)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x3yeg5enb12drtr5aobl.JPG)

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](https://stackoverflow.com/questions/727928/django-admin-how-to-access-the-request-object-in-admin-py-for-list-display-met/23574558#23574558?newreg=fca9027c603a4c18b9f6e584986b3f2a) 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](https://gist.github.com/vparitskiy/71bb97b4fd2c3fbd6d6db81546622346) 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](https://dev.to/danilovmy/django-admin-dynamic-inline-positioning-3cam):

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

{% gist https://gist.github.com/danilovmy/0c8daa566aece6926b590bb9c80ff435 %}

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

|default django admin panel|modified admin panel|
|-|-|
|![Time 1 default django admin panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f8ki4w9meqdwz7pfcrod.PNG) | ![Time 1 cached solution](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/se7nildbtoipm38furn5.PNG) |
| ![Time 2 default django admin panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e6ikna8xun39nz0kivkx.PNG) | ![Time 2 cached solution](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7268fyr7jy5jkr4w2b8v.PNG) |
| ![Time 3 default django admin panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ngddfpppamflclegx861.PNG) | ![Time 3 cached solution](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m19f3z3n1s5skw9b67d9.PNG) |
|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:

{% embed https://gist.github.com/danilovmy/98a088a9fc7bf1ff2129c792ca169136 %}

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:
{% embed https://gist.github.com/danilovmy/f12c7a2e33cc1f2d583ab4dcb77c7497 %}

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:

{% embed https://gist.github.com/danilovmy/2a0209de4e3020fe52d53e55859a204d %}

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 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:

{% embed https://gist.github.com/danilovmy/5407feee16f20804c05710ced5796acc %}

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.

{% embed https://gist.github.com/danilovmy/2ca7c2b9176bb2612cd8ddd9dda47e60 %}

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:

{% embed https://gist.github.com/danilovmy/b83e87b3f6827fa5c511a7f5458a7dc6 
 %}

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](https://docs.python.org/3/library/functools.html#functools.lru_cache). 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](https://code.djangoproject.com/ticket/33028), 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](https://dev.to/danilovmy/the-python-and-django-conferences-you-dont-want-to-miss-in-2022-2ki0) that I will be speaking at that conference.

{% embed https://youtu.be/Vg8E2fnoR78 %}