Django admin panel is one of the biggest strengths on Django. It allows quickly have interface access data stored in DB, have forms to add and edit data and manage users.

But since in its default state Django Admin Site is quite basic, so in this article I want to go over all the ways to make the most out of it by customising it.

In this article:

Django project setup for customising admin site

In order to make all the code easily accessible I have created a Django project from scratch and created a git repository, so you can see the end project in case you want to see how all pieces work together.

Here is the repository: https://github.com/appliku/admin_customization

To allow you follow along here is the whole process.

Let's start with project creation:

cd ~/src # i keep all my projects in the src directory in my users $HOME

mkdir admin_customization
cd admin_customization
python3 -m venv env/
source env/bin/activate
pip install --upgrade pip
pip install django
django-admin startproject project .
pip install django-environ
pip freeze > requirements.txt
./manage.py startapp myapp

Open project/settings.py and add 'myapp' to the INSTALLED_APPS.

Run migrations

python manage.py makemigrations
python manage.py migrate

Now let's create a superuser.

DJANGO_SUPERUSER_PASSWORD=somethingsupersecret123 python manage.py createsuperuser --username=admin --email=admin@example.com --noinput

If you are looking for a deployment tutorial for your Django app check one of these:

Django Admin Site Tutorial

Let's start with the basics first.

The admin site is enabled by default when you create a project with startproject command.

If you didn't use the default project then make sure that these requirements are met: - The following lines are added in INSTALLED_APPS: django.contrib.admin, django.contrib.auth, django.contrib.contenttypes,django.contrib.messages, django.contrib.sessions - The TEMPLATES setting must include django.template.backends.django.DjangoTemplates backend like this:

#  settings.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent

# ...

TEMPLATES = [
        {
            'BACKEND': 'django.template.backends.django.DjangoTemplates',
            'DIRS': [BASE_DIR / 'templates'],
            'APP_DIRS': True,
            'OPTIONS': {
                'context_processors': [
                    'django.template.context_processors.debug',
                    'django.template.context_processors.request',
                    'django.contrib.auth.context_processors.auth',
                    'django.contrib.messages.context_processors.messages',
                ],
            },
        },
    ]  
  • The MIDDLEWARE setting must include django.contrib.auth.middleware.AuthenticationMiddleware and django.contrib.messages.middleware.MessageMiddleware.
  • Admin must be included into the URLconf. This one you want to alter for improved security. Either hardcode it to be anything different from the standard /admin/ or better yet – make it use an environment variable so you can set a different secret admin url in production.

How to set secret Django admin URL from environment variable

In your settings.py you should set a variable, let's call it ADMIN_URL. In this example we'll use the django-environ package to work with env vars.

If you don't have it installed please do:

python -m pip install django-environ

And include it in your requirements.txt .

In settings.py you should import the environ and create env object. Then you can set the value for the ADMIN_URL defaulting to something that is not the standard admin/.

#  settings.py

import environ

env = environ.Env()

ADMIN_URL = env('ADMIN_URL', default='notadmin123/')

Now open the main urls.py and in the urlpatterns list add the admin line (or edit the standard admin/ one if you have it already).

# urls.py

from django.conf import settings  
from django.contrib import admin  
from django.urls import path, include


urlpatterns = [
    # ... your other URLs

    path(settings.ADMIN_URL, admin.site.urls),

    # ... your other URLs
]

Setting up models and basic ModelAdmin classes

In order to demonstrate how to overcome some struggles that you might have in real apps we will need several models with various types of relationships. I will also show an example where something should go into models and be editable in the admin and what can be hardcoded because it won't be changed that often.

Our example app will be an oversimplified e-commerce CRM/Order management.

The database will consist of the following models:

– Category – Product – Customer – Order – Order Log

Here is the content of myapp/models.py:

from django.db import models
from django.utils import timezone

from myapp.tuples import ORDER_STATUSES_CHOICES, ORDER_STATUSES


class Category(models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    is_active = models.BooleanField(default=True, db_index=True)

    class Meta:
        ordering = ('name',)
        verbose_name = 'category'
        verbose_name_plural = 'categories'

    def __str__(self):
        return self.name


class Product(models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    category = models.ManyToManyField(Category, related_name='products')
    is_active = models.BooleanField(default=True, db_index=True)

    class Meta:
        ordering = ('name',)
        verbose_name = 'product'
        verbose_name_plural = 'products'

    def __str__(self):
        return self.name


class Customer(models.Model):
    first_name = models.CharField(max_length=200)
    last_name = models.CharField(max_length=200)
    phone = models.CharField(max_length=200, unique=True)

    class Meta:
        ordering = ('first_name',)
        verbose_name = 'customer'
        verbose_name_plural = 'customers'

    def __str__(self):
        return ' '.join([self.first_name, self.last_name])


class Order(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    created_dt = models.DateTimeField(auto_now_add=True)
    completed_dt = models.DateTimeField(null=True, blank=True)
    status = models.IntegerField(default=ORDER_STATUSES.new, choices=ORDER_STATUSES_CHOICES)

    class Meta:
        ordering = ('-created_dt',)
        verbose_name = 'order'
        verbose_name_plural = 'orders'

    def __str__(self):
        return str(self.id)

    def save(self, *args, **kwargs):
        if self.completed_dt:
            self.status = ORDER_STATUSES.complete
        if self.status == ORDER_STATUSES.complete and not self.completed_dt:
            self.completed_dt = timezone.now()
        super().save(*args, **kwargs)


class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField(default=1)

    class Meta:
        verbose_name = 'order item'
        verbose_name_plural = 'order items'
        unique_together = ('order', 'product')

    def __str__(self):
        return self.product.name

Create a file myapp/tuples.py and put this in it.

from collections import namedtuple

ORDER_STATUSES = namedtuple('ORDER_STATUSES', 'new processing shipped complete canceled')._make(range(5))

ORDER_STATUSES_CHOICES = (
    (ORDER_STATUSES.new, 'New'),
    (ORDER_STATUSES.processing, 'Processing'),
    (ORDER_STATUSES.shipped, 'Shipped'),
    (ORDER_STATUSES.complete, 'Complete'),
    (ORDER_STATUSES.canceled, 'Canceled'),
)

Now create new migrations and apply them:

python manage.py makemigrations
python manage.py migrate

Now let's run the development server and open our admin panel:

python manage.py runserver

The output should be like this:

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
July 09, 2023 - 07:18:13
Django version 4.2.3, using settings 'project.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Open your browser at this URL: (http://127.0.0.1:8000/notadmin123/)[http://127.0.0.1:8000/notadmin123/] because that's where our admin is located.

The credentials are admin / somethingsupersecret123

You will see this basic admin panel without our models in there.

image

Let's fix that.

How to register Django model in the admin site

Open the myapp/admin.py file and let's create very basic admin models.

from django.contrib import admin
from .models import Category, Product, Customer, Order


class CategoryAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug', 'is_active', 'id',)


admin.site.register(Category, CategoryAdmin)


class ProductAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug', 'is_active', 'id',)


admin.site.register(Product, ProductAdmin)


class CustomerAdmin(admin.ModelAdmin):
    list_display = ('first_name', 'last_name', 'phone', 'id',)


admin.site.register(Customer, CustomerAdmin)


class OrderAdmin(admin.ModelAdmin):
    list_display = ('customer', 'created_dt', 'completed_dt', 'status', 'id',)


admin.site.register(Order, OrderAdmin)

Refresh the admin page and you should see the "MYAPP" block appear and our 4 models listed.

image

In this step, we have registered models in the admin panel. Thanks to the list_display attribute, we tell the Django admin site what fields to display in the table for each model.

I will create a few records in every table so we can see how our admin modifications affect the admin site.

Pre-populate slug fields in the Django admin site

If you have started filling the database with data, you might have noticed that you have to manually enter values for the slug field. Luckily, Django has a feature to automatically populate the content of a SlugField.

And this will be our first admin site customization.

In the file myapp/admin.py add the prepopulated_fields attribute to CategoryAdmin and ProductAdmin classes:

class CategoryAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}  # new
    list_display = ('name', 'slug', 'is_active', 'id',)


admin.site.register(Category, CategoryAdmin)


class ProductAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}  # new
    list_display = ('name', 'slug', 'is_active', 'id',)


admin.site.register(Product, ProductAdmin)

The prepopulated_fields attribute will make the Django admin site fill the slug field with what you type into the product or category name field during the creation of the record. It will not be changing the slug's value during the editing process though, because changing slugs is usually undesired behavior because it changes URLs.

Now when adding a category, I only had to type the category's name, and the slug field is automatically filled with the appropriate slug.

image

I have created four categories, and the change list looks like this: image

Now I want to create some products. The slug field is populated from the name, which is cool. But to select multiple categories, I would have to use CTRL/CMD click, which is inconvenient. Plus we are here to show off Django admin site customization kung fu 🤣

Let's edit our myapp/admin.py to add filter_horizontal

class ProductAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    list_display = ('name', 'slug', 'is_active', 'id',)
    filter_horizontal = ('category',)  # new


admin.site.register(Product, ProductAdmin)

Now we go back to the form and the "category" field looks much better: image

Customise Ordering in Django Admin

If you need to customise ordering in the change list you can approach it two ways.

The first one is to apply ordering in the model itself

# models.py
class SomeModel(models.Model):
    name = models.CharField(...)

    class Meta:
        ordering = ('name', )

or if you want this customisation only apply to admin then do it in the ModelAdmin

# admin.py

class SomeModelAdmin(admin.ModelAdmin):
    ordering = ('name',)

Search objects in Django Admin

Built-in search functionality in Django Admin

I have added a bit more products and it looks amazing, but something is missing.

image Since we have so many products we need a way to search for what we need.

Let's add a search bar.

Add search_fields to the ProductAdmin

class ProductAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    list_display = ('name', 'slug', 'is_active', 'id',)
    filter_horizontal = ('category',)
    search_fields = ('name',) #  new


admin.site.register(Product, ProductAdmin)

And now we have this small search bar on top of the list of products. It will search over the name field in our model.

image

But we want some more tools for finding the right item and for that we need filters.

Advanced search and custom search tools in Django Admin

For the advanced search features let's install djangoql package.

Run this command within the virtual env:

pip install djangoql==0.17.1

and add djangoql==0.17.1 to the requirements.txt

Add 'djangoql' to INSTALLED_APPS in your settings.py:

INSTALLED_APPS = [
    ...
    'djangoql',
    ...
]

Adding DjangoQLSearchMixin your model admin will replace the standard Django search functionality with DjangoQL search. DjangoQL will recognise if you have defined search_fields in your ModelAdmin class, and doing so will allow you to choose between an advanced search with DjangoQL and a standard Django search (as specified by search fields).

Example for our Product model:

from djangoql.admin import DjangoQLSearchMixin

class ProductAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    list_display = ('name', 'slug', 'is_active', 'id',)
    filter_horizontal = ('category',)
    search_fields = ('name',)
    list_filter = ('category', 'is_active',)


admin.site.register(Product, ProductAdmin)

image

Adding filters to Django Admin Site Change List

In order to add filters you need to add list_filter attribute to the ModelAdmin.

So go to myapp/admin.py and add a list_filter attribute to our ProductAdmin class

class ProductAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    list_display = ('name', 'slug', 'is_active', 'id',)
    filter_horizontal = ('category',)
    search_fields = ('name',)
    list_filter = ('category', 'is_active',) #  new


admin.site.register(Product, ProductAdmin)

Now you can see filters to the right from the change list:

image

Writing custom filters for Django Admin Site

There is a plenty of opportunity how to add custom filters, but there are a few typical cases for that.

Custom Django Admin filter multiple values

Look at our Order model. Imagine you want to see all orders in any active state? This would include "New", "Processing" and "Shipped".

Let's make it.

image

First, I have created 1 order in each status.

Let's see what happens if I just add a list_filter attribute with status:

# myapp/admin.py

class OrderAdmin(admin.ModelAdmin):  
    list_display = ('customer', 'created_dt', 'completed_dt', 'status', 'id',)  
    list_filter = ('status',)  


admin.site.register(Order, OrderAdmin)

image

Such filter doesn't help a manager or store owner who wants to see all active orders at a glance.

Let's make a custom admin filter for order status and add it to list_filter

class OnlyActiveOrdersFilter(admin.SimpleListFilter):
    title = 'Show Only Active Orders'
    parameter_name = 'status'

    def lookups(self, request, model_admin):
        return (
            ('active', 'Active'),
        )

    def queryset(self, request, queryset):
        if self.value() == 'active':
            return queryset.filter(status__in=(ORDER_STATUSES.new, ORDER_STATUSES.processing, ORDER_STATUSES.shipped))
        return queryset


class OrderAdmin(admin.ModelAdmin):
    list_display = ('customer', 'created_dt', 'completed_dt', 'status', 'id',)
    list_filter = ('status', OnlyActiveOrdersFilter,)


admin.site.register(Order, OrderAdmin)

image

As you can see if you select filter by "Active" then there will only be 3 orders in the list in statuses "Shipped", "Processing" and "New".

CSV and Excel Import and Export in Django Admin Site

The whole corporate world is powered by spreadsheets.

Spreadsheets are the most popular way of bulk import and updating data.

Luckily, we have a great package called django-import-export which makes it super easy to enabled CSV, XLS, etc import export for our models in the admin site.

Install the package

pip install django-import-export

and add it to "requirements.txt":

django-import-export==3.2.0

Add it to your INSTALLED_APPS:

# settings.py

INSTALLED_APPS = [
    ...
    'import_export',

]

Documentation says you also need to run python manage.py collectstatic in order for it to work.

In the myapp/admin.py add two imports and add a resource for our import & export operations.

Since we are using other mixin for our ProductAdmin let's keep being consistent and add ImportExportMixin from import_export package instead of the ImportExportModelAdmin.

# admin.py

from import_export import resources  
from import_export.admin import ImportExportMixin

...


class ProductResource(resources.ModelResource):
    class Meta:
        model = Product
        fields = ('name', 'slug', 'is_active', 'id',)


class ProductAdmin(ImportExportMixin, DjangoQLSearchMixin, admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    list_display = ('name', 'slug', 'is_active', 'id',)
    filter_horizontal = ('category',)
    search_fields = ('name',)
    list_filter = ('category', 'is_active',)
    resource_classes = (ProductResource,)

Now in our we see two buttons added in our Django Admin Site change list: Import and Export

image

If we click on the "Import" button we'll see this form where they hint you which fields are expected, file to import and format.

image

CSV export for model in Django Admin Site

If we click on the "Export" button we see the export form with only format selection

image Exported file looks like this:

image

The list of objects that will get exported depends on filters and search queries in the change list. So which queryset is used to display objects will be also used for the export.

To demonstrate that I have set is_active=False for a couple of products and searched by is_active=False

image

And in the export we get only inactive products.

image

CSV Import and update in Django Admin site

Let's now change some product names and import this file back

I have changed names of both products, leaving the rest as is in this XLS file.

image

Going back to the change list, clicking export and in the import form I am selecting the file to import and XLS format, then submit.

image

I get a confirmation step where the difference with the existing data is shown.

image

Click "Confirm Import" and see that our data has indeed changed!

image

Let's look at our order list in Django Admin. I have slightly changed the OrderAdmin because I previously put the ID field as the last column and the customer field as the first and since it was the first field it became a link. This might confuse the user of such interface because link opens the order and not the customer.

And I need this customer field for the demonstration of making a link to a related object.

So this is our OrderAdmin in the code:

class OrderAdmin(admin.ModelAdmin):
    list_display = ('id', 'created_dt', 'completed_dt', 'status', 'customer', )
    list_filter = ('status', OnlyActiveOrdersFilter,)


admin.site.register(Order, OrderAdmin)

And here is how it looks like:

image

Let's make it easier to click first by using list_display_links attribute where we'll list which fields should become links to the edit form of the model. This is a very convenient feature, because numbers are very hard to click at, especially small numbers.

# admin.py


class OrderAdmin(admin.ModelAdmin):
    list_display = ('id', 'created_dt', 'completed_dt', 'status', 'customer', )
    list_filter = ('status', OnlyActiveOrdersFilter,)
    list_display_links = ('id', 'created_dt',)


admin.site.register(Order, OrderAdmin)

Here we go, both columns ID and created_dt fields are both clickable.

image

Now let's make the customer column value clickable, but it will lead to the customer edit page.

If you just include customer in the list_display_links it will not make it linked to the customer page, but will be yet another field that has a link to the ORDER model.

In order to link to the customer edit page we need to add a method and include that method in the list_display.

This method will return a piece of HTML and must be marked safe so that Django injects HTML and not an escaped text.

# admin.py

class OrderAdmin(admin.ModelAdmin):
    list_display = (
        'id',
        'created_dt',
        'completed_dt',
        'status',
        'link_to_customer', # new
    )
    list_filter = ('status', OnlyActiveOrdersFilter,)
    list_display_links = ('id', 'created_dt',)

    def link_to_customer(self, obj):  # new
        link = reverse("admin:myapp_customer_change", args=[obj.customer.id])
        return format_html(
            '<a href="{}">{}</a>',
            link,
            obj.customer,
        )

    link_to_customer.short_description = 'Customer' # new


admin.site.register(Order, OrderAdmin)

image

Now the link to customer looks like this: /notadmin123/myapp/customer/1/change/ and leads to the customer edit form:

image

By the way, it is important to make a performance optimisation here. Let's add list_select_related here. We can either set it to True(the default is False) or be more specific and provide a specific list of foreign keys to select.

If set to True or the list/tuple it would use the select_related in the queryset which would load OneToOneField and ForeignKeyField values in a single SELECT statement avoiding the need to send a separate query for every object.

class OrderAdmin(admin.ModelAdmin):
    list_display = (
        'id',
        'created_dt',
        'completed_dt',
        'status',
        'link_to_customer',
    )
    list_filter = ('status', OnlyActiveOrdersFilter,)
    list_display_links = ('id', 'created_dt',)
    list_select_related = ('customer',)  # new

    def link_to_customer(self, obj):
        link = reverse("admin:myapp_customer_change", args=[obj.customer.id])
        return format_html(
            '<a href="{}">{}</a>',
            link,
            obj.customer,
        )

    link_to_customer.short_description = 'Customer'


admin.site.register(Order, OrderAdmin)