Welcome to Mastering Django Admin Book!

Preface

Why this book?

In this data driven world, internal tools are often overlooked parts in companies. Without efficient tools for analytics and dashboards, cross department communications & customer communications become a bottleneck as people spend more time everyday to get some data.

There are several tools built specifically for analytics and dashboards. But if we are already using Django framework, we can just use Django Admin for most of these tasks.

In this book, we will learn how to customize Django admin for these tasks.

Pre requisites

Readers should be familiar with creating a model/view using Django. If you are new to django, complete the polls tutorial 1 provided in the official Django documentation to get familiar about Django framework.

Who should read this book?

Anyone who wants to learn how to customize and improve the performance of django admin.

Acknowledgements

1

https://docs.djangoproject.com/en/3.0/intro/tutorial01/

The Million Dollar Admin

Django admin was first released in 2005 and it has gone through a lot of changes since then. Still the admin interface looks clunky compared to most modern web interfaces.

Jacob Kaplan-Moss, one of the core-developers of Django estimated that it will cost 1 million dollars 1 to hire a team to rebuild admin interface from scratch. Until we get 1 million dollars to revamp the admin interface, let’s look into alternate solutions.

  1. Use django admin with modern themes/skins. Packages like django-grappelli 2, django-suit 3 extend the existing admin interface and provide new skin,options to customize the UI etc.

  2. Use drop-in replacements for django admin. Packages like xadmin 4, django-admin2 5 are a complete rewrite of django admin. Even though these packages come with lot of goodies and better defaults, they are no longer actively maintained.

  3. Use seperate django packages per task.

  4. Write our own custom admin interface.

We can start default admin interface or use any drop-in replacements for the admin. Even with this admin interface, we need to write custom views/reports based on business requirements.

In the next chapter, lets start with customizing admin interface.

1

https://jacobian.org/2016/may/26/so-you-want-a-new-admin/

2

https://pypi.org/project/django-grappelli/

3

https://pypi.org/project/django-suit/

4

https://pypi.org/project/xadmin/

5

https://pypi.org/project/django-admin2/

Better Defaults

Use ModelAdmin

When a model is registered with admin, it just shows the string representation of the model object in changelist page.

from book.models import Book

admin.site.register(Book)
_images/admin-defaults-list.png

Django provides ModelAdmin 1 class which represents a model in admin. We can use the following options to make the admin interface informative and easy to use.

  • list_display to display required fields and add custom fields.

  • list_filter to add filters data based on a column value.

  • list_per_page to set how many items to be shown on paginated page.

  • search_fields to search for records based on a field value.

  • date_hierarchy to provide date-based drilldown navigation for a field.

  • readonly_fields to make seleted fields readonly in edit view.

  • prepopulated_fields to auto generate a value for a column based on another column.

  • save_as to enable save as new in admin change forms.

from book.models import Book
from django.contrib import admin


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ('id', 'name', 'author', 'published_date', 'cover', 'is_available')
    list_filter = ('is_available',)
    list_per_page = 10
    search_fields = ('name',)
    date_hierarchy = 'published_date'
    readonly_fields = ('created_at', 'updated_at')
_images/admin-defaults-list2.png

In list_display in addition to columns, we can add custom methods which can be used to show calculated fields. For example, we can change book color based on its availability.

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ('id', 'name_colored', 'author', 'published_date', 'cover', 'is_available')

    def name_colored(self, obj):
        if obj.is_available:
            color_code = '00FF00'
        else:
            color_code = 'FF0000'
        html = '<span style="color: #{};">{}</span>'.format(color_code, obj.name)
        return format_html(html)

    name_colored.admin_order_field = 'name'
    name_colored.short_description = 'name'
_images/admin-defaults-list3.png

Use Better Widgets

Sometimes widgets provided by Django are not handy to the users. In such cases it is better to add tailored widgets based on the data.

For images, instead of showing a link, we can show thumbnails of images so that users can see the picture in the list view itself.

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
  list_display = ('id', 'name_colored', 'thumbnail', 'author', 'published_date', 'is_available')

  def thumbnail(self, obj):
    width, height = 100, 200
    html = '<img src="/{url}" width="{width}" height={height} />'
    return format_html(
        html.format(url=obj.cover.url, width=width, height=height)
    )

This will show thumbnail for book cover images.

_images/defaults-widget1.png

Viewing and editing JSON field in admin interface will be very difficult in the textbox. Instead, we can use JSON Editor widget provided any third-party packages like django-json-widget, with which viewing and editing JSON data becomes much intuitive.

CSV and Excel imports and exports

from django.contrib.postgres import fields
from django_json_widget.widgets import JSONEditorWidget

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    formfield_overrides = {
        fields.JSONField: {
            'widget': JSONEditorWidget
        },
    }

With this, all JSONFields will use JSONEditorWidget, which makes it easy to view and edit json content.

_images/defaults-widget3.png

There are a wide variety of third-party packages like django-map-widgets, django-ckeditor, django-widget-tweaks etc which provide additional widgets as well as tweaks to existing widgets.

Better Defaults For Models

We can set user friendly names instead of default names for django models in admin. We can override this in model meta options.

class Category(models.Model):
    class Meta:
        verbose_name = "Book Category"
        verbose_name_plural = "Book Categories"

Model fields has an option to enter help_text which is useful documentation as well as help text for forms.

class Book(TimeAuditModel):
    is_available = models.BooleanField(
        help_text='Is the book available to buy?'
    )
    published_date = models.DateField(
        help_text='help_text="Please enter the date in <em>YYYY-MM-DD</em> format.'
    )

This will be shown in admin as shown below.

_images/admin-defaults-list4.png

Managing Model Relationships

Auto Generate Admin Interface

Manual Registration

Inbuilt admin interface is one the most powerful & popular feature of Django. Once we create the models, we need to register them with admin, so that it can read schema and populate interface for it.

Let us register Book model in the admin interface.

# file: library/book/admin.py

from django.apps import apps

from book.models import Book


class BookAdmin(admin.ModelAdmin):
    list_display = ('id', 'name', 'author')


admin.site.register(Book, BookAdmin)

Now, we can see the book model in admin.

_images/admin-auto-register1.png

If the django project has too many models to be registered in admin or if it has a legacy database where all tables need to be registered in admin, then adding all those models to admin becomes a tedious task.

Auto Registration

To automate this process, we can programatically fetch all the models in the project and register them with admin. Also, we need to ignore models which are already registered with admin as django doesn’t allow regsitering same model twice.

from django.apps import apps


models = apps.get_models()

for model in models:
    try:
        admin.site.register(model)
    except admin.sites.AlreadyRegistered:
        pass

This code snippet should run after all admin.py files are loaded so that auto registration happends after all manually added models are registered. Django provides AppConfig.ready() to perform any initialization tasks which can be used to hook this code.

# file: library/book/apps.py

from django.apps import apps, AppConfig
from django.contrib import admin


class BookAppConfig(AppConfig):

    def ready(self):
        models = apps.get_models()
        for model in models:
            try:
                admin.site.register(model)
            except admin.sites.AlreadyRegistered:
                pass

In the admin, we can see manually registered models and automatically registered models. If we open admin page for any auto registered model, it will show something like this.

_images/admin-auto-register2.png

This view is not at all useful for the users who want to see the data. It will be more informative if we can show all the fields of the model in admin.

Auto Registration With Fields

To achieve that, we can create an admin class to populate model fields in list_display. While registering, we can use this admin class to register the model.

from django.apps import apps, AppConfig
from django.contrib import admin


class ListModelAdmin(admin.ModelAdmin):
    def __init__(self, model, admin_site):
        self.list_display = [field.name for field in model._meta.fields]
        super().__init__(model, admin_site)


class BookAppConfig(AppConfig):

    def ready(self):
        models = apps.get_models()
        for model in models:
            try:
                admin.site.register(model, ListModelAdmin)
            except admin.sites.AlreadyRegistered:
                pass

Now, if we look at Author admin page, it will be shown with all relevant fields.

_images/admin-auto-register3.png

Since we have auto registration in place, when a new model is added or columns are altered for existing models, admin interface will update accordingly without any code changes.

Admin Generator

The above methods will be useful to generate a pre-defined admin interface for all the models. If independent customizations are needed for the models, then we use 3rd party packages like django-admin-generator or django-extensions which can generate a fully functional admin interface by introspecting the models. Once the base admin code is ready, we can use the same for futher customizations.

$ ./manage.py admin_generator books >> books/admin.py

This will generate admin interface for books app.

1

https://github.com/WoLpH/django-admin-generator

2

https://django-extensions.readthedocs.io/en/latest/admin_generator.html

Filtering In Admin

Search Fields

Django Admin provies search_fields option on ModelAdmin. Setting this will enable a search box in list page to filter items on the model. This can perform lookup on all the fields on the model as well as related model fields.

class BookAdmin(admin.ModelAdmin):
    search_fields = ('name', 'author__name')
_images/filter1.png

When the number of items in search_fields becomes increases, query becomes quite slow as it does a case-insensitive search of all the search terms against all the search_fields. For example a search for python for data analysis translates to this SQL caluse.

WHERE
(name ILIKE '%python%' OR author.name ILIKE '%python%')
AND (name ILIKE '%for%' OR author.name ILIKE '%for%')
AND (name ILIKE '%data%' OR author.name ILIKE '%data%')
AND (name ILIKE '%analysis%' OR author.name ILIKE '%analysis%')

List Filters

Django also provides list_filter option on ModelAdmin. We can add required fields to list_filter which generate corresponding filters on the right panel of the admin page with all the possible values.

class BookAdminFilter(admin.ModelAdmin):
    list_display = ('id', 'author', 'published_date', 'is_available', 'cover')
    list_filter = ('is_available',)
_images/filter2.png

Custom List Filters

We can also write custom filters so that we can set calculated fields and add filters on top of them.

class CenturyFilter(admin.SimpleListFilter):
    title = 'century'
    parameter_name = 'published_date'

    def lookups(self, request, model_admin):
        return (
            (21, '21st century'),
            (20, '20th century'),
        )

    def queryset(self, request, queryset):
        value = self.value()
        if not value:
            return queryset
        start = (int(value) - 1)* 100
        end = start + 99
        return queryset.filter(published_date__year__gte=start, published_date__year__lte=end)
_images/filter3.png

Custom Text Filter

Here the number of choices are limited. But in some cases where the choices are hundred or more, it is better to display a text input instead of choices.

Let’s write a custom filter to filter books by published year. Let’s write an input filter

class PublishedYearFilter(admin.SimpleListFilter):
    title = 'published year'
    parameter_name = 'published_date'
    template = 'admin_input_filter.html'

    def lookups(self, request, model_admin):
        return ((None, None),)

    def choices(self, changelist):
        query_params = changelist.get_filters_params()
        query_params.pop(self.parameter_name, None)
        all_choice = next(super().choices(changelist))
        all_choice['query_params'] = query_params
        yield all_choice

    def queryset(self, request, queryset):
        value = self.value()
        if value:
            return queryset.filter(published_date__year=value)

This will show in admin like this.

{% load i18n %}

<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<ul>
    <li>
        {% with choices.0 as all_choice %}
            <form method="GET">
                <input type="text" name="{{ spec.parameter_name }}" value="{{ spec.qvalue|default_if_none:"" }}"/>
                <input class="btn btn-info" type="submit" value="{% trans 'Apply' %}">
                {% if not all_choice.selected %}
                    <button type="button" class="btn btn-info"><a href="{{ all_choice.query_string }}">Clear</a></button>
                {% endif %}
            </form>
        {% endwith %}
    </li>
</ul>
_images/filter4.png

https://stackoverflow.com/a/20588975/2698552

Advanced Filters

All the above methods will be useful only to a certain extent. Beyond that, there are 3rd party packages like django-advanced-filters which advanced filtering abilites.

To setup the package

  • Install the package with pip install django-advanced-filters.

  • Add advanced_filters to INSTALLED_APPS.

  • Add url(r’^advanced_filters/’, include(‘advanced_filters.urls’)) to project urlconf.

  • Run python manage.py migrate.

Once the setup is completed, we can add ``

from advanced_filters.admin import AdminAdvancedFiltersMixin

class BookAdAdminFilter(AdminAdvancedFiltersMixin, admin.ModelAdmin):
    list_display = ('id', 'name', 'author', 'published_date', 'is_available', 'name')
    advanced_filter_fields = ('name', 'published_date', 'author', 'is_available')

In the admin page, a popup like this will be shown to apply advanced filers.

_images/filter5.png

A simple filter can be created to filter all the books that were published between 1980 to 1990 which have a rating more than 3.75 and number of pages is not more than 100. This filter can be named and saved for later use.

Custom Admin Actions

Bulk Editing In List View

For data cleanup and heavy content updates, bulk editing on a model makes workflow easier. Django provides list_editable option to make selected fields editable in the list view itself.

class BestSellerAdmin(RelatedFieldAdmin):
    list_display = ('id', 'book', 'year', 'rank')
    list_editable = ('book', 'year', 'rank')

This allows to edit the above mentioned fields as shown.

_images/1.png

Custom Actions On Querysets

Django provides admin actions which work on a queryset level. By default, django provides delete action in the admin.

In our books admin, we can select a bunch of books and delete them.

_images/51.png

Django provides an option to hook user defined actions to run additional actions on selected items. Let us write write a custom admin action to mark selected books as available.

class BookAdmin(admin.ModelAdmin):
    actions = ('make_books_available',)
    list_display = ('id', 'name', 'author')

    def make_books_available(self, modeladmin, request, queryset):
        queryset.update(is_available=True)
    make_books_available.short_description = "Mark selected books as available"
_images/admin-custom-actions2.png

Instead of having custom actions in the drop down, we can put dedicated butons for most frequently used actions to reduce number of clicks needed to perform an action.

https://github.com/crccheck/django-object-actions

Custom Actions On Individual Objects

Custom admin actions are inefficient when taking action on an individual object. For example, to delete a single user, we need to follow these steps.

  1. Select the checkbox of the object.

  2. Click on the action dropdown.

  3. Select “Delete selected” action.

  4. Click on Go button.

  5. Confirm that the objects needs to be deleted.

Just to delete a single record, we have to perform 5 clicks. That’s too many clicks for a single action.

To simplify the process, we can have delete button at row level. This can be achieved by writing a function which will insert delete button for every record.

ModelAdmin instance provides a set of named URLs for CRUD operations. To get object url for a page, URL name will be {{ app_label }}_{{ model_name }}_{{ page }}.

For example, to get delete URL of a book object, we can call reverse(“admin:book_book_delete”, args=[book_id]). We can add a delete button with this link and add it to list_display so that delete button is available for individual objects.

from django.contrib import admin
from django.utils.html import format_html

from book.models import Book


class BookAdmin(admin.ModelAdmin):
    list_display = ('id', 'name', 'author', 'is_available', 'delete')

    def delete(self, obj):
        view_name = "admin:{}_{}_delete".format(obj._meta.app_label, obj._meta.model_name)
        link = reverse(view_name, args=[book.pk])
        html = '<input type="button" onclick="location.href=\'{}\'" value="Delete" />'.format(link)
        return format_html(html)

Now in the admin interface, we have delete button for individual objects.

_images/admin-custom-actions3.png

To delete an object, just click on delete button and then confirm to delete it. Now, we are deleting objects with just 2 clicks.

In the above example, we have used an inbuilt model admin delete view. We can also write custom view and link those views for custom actions on individual objects. For example, we can add a button which will mark the book status to available.

Custom Actions On Change View

When users want to conditionaly perform a custom action when an object gets modified, custom action buttons can be provided on the change view. For example, when a best seller is updated, notify the author of the best seller via an email.

We can override change_form.html to include a button for custom action.

{% extends 'admin/change_form.html' %}

{% block submit_buttons_bottom %}
    {{ block.super }}
    <div class="submit-row">
            <input type="submit" value="Notify Author" name="notify-author">
    </div>
{% endblock %}

In the admin view, we have to override response_change to handle the submit button press.

class BestSellerAdmin(admin.ModelAdmin):
    change_form_template = "bestseller_changeform.html"

    def response_change(self, request, obj):
        if "notify-author" in request.POST:
            send_best_seller_email(obj)
            self.message_user(request, "Notified author abouthe the best seller")
            return HttpResponseRedirect(request.path_info)
        return super().response_change(request, obj)

This will show a button on the change form as shown below.

_images/3.png

Add confirmation page for potentialy dangerous actions.

There is a 3rd party package django-admin-row-actions, which provides a mixin to create custom admin actions.

https://github.com/DjangoAdminHackers/django-admin-row-actions

In this chapter, we have seen how to write custom admin actions which work on single item as well as bulk items.

Securing Django Admin

Once the Django Admin is up and running with required functionality, it is necessary to ensure that it doesn’t not have unattended access.

First, the server infrastrucutre needs to be secured. This is topic itself can be written as a seperate book. How to secure a linux server 1 guide provides detailed instructions on how to secure a server.

Next, we need to ensure our Django application is secure. Django provides documentation 2 on how to secure a django powered site.

Django provides system check to inspect entire code base and report common issues. We can run this command with –deploy which activates additional checks for deployment.

$ python manage.py check --deploy

In this chapter let us focus at Admin related security measures to make it secure.

1

https://github.com/imthenachoman/How-To-Secure-A-Linux-Server

2

https://docs.djangoproject.com/en/3.0/topics/security/

Admin Path

Most of the django sites use /admin/ as the default path for admin interface. This needs to be changed to a different path.

url(r'^secret-path/', admin.site.urls)

We can write a system check to check if /admin/ path is used and raise an error.

from django.conf import settings
from django.core.checks import Error, Tags, register
from django.urls import resolve


@register(Tags.security, deploy=True)
def check_admin_path(app_configs, **kwargs):
    errors = []
    try:
        default_admin = resolve('/admin/')
    except Resolver404:
        default_admin = None
    if default_admin:
       msg = 'Found admin in default "/admin/" path'
       hint = 'Route admin via different url'
       errors.append(Error(msg, hint))

    return errors

Instead of removing admin, We can also setup a honeypot at the default path which will serve a fake login page. To install honeypot 3, run pip install django-admin-honeypot with pip, add admin_honeypot to INSTALLED_APPS and set default path to honeypot path in urls.

url(r'^admin/', include('admin_honeypot.urls', namespace='admin_honeypot'))

Now, we can track all the login attempts on the honeypot admin from the admin page.

_images/secure1.png
3

https://github.com/dmpayton/django-admin-honeypot

2 Factor Authentication

To make Admin more secure, we can enable 2 step verification where user has to provide 2 different authentication factors, one is password and the other is OTP generated from user mobile.

For this, we can use django-otp 4 package and create a custom admin config to replace default admin site.

4

https://pypi.org/project/django-otp/

Install the package with pip install django-otp, add django_otp, django_otp.plugins.otp_totp to installed apps and run ./manage.py migrate.

In the admin page, under OTP_TOTP section add new device so that we can generate OTP for the admin page.

Create 2 files admin.py & apps.py in the project package to create custom admin config for OTPAdminSite and set it as default.

from django_otp.admin import OTPAdminSite

class LibraryOTPAdminSite(OTPAdminSite):
    pass
from django.contrib.admin.apps import AdminConfig

class LibraryAdminConfig(AdminConfig):
    default_site = 'library.admin.LibraryOTPAdminSite'

In the INSTALLED_APPS, replace admin with custom config.

INSTALLED_APPS = [
    # 'django.contrib.admin',
    'library.apps.LibraryAdminConfig',
]

Now the admin login page will show OTP login form.

_images/secure3.png

Environments

When the django app is deployed in multiple environments, it is important to distinguish those environments visually so that admin users accidentally don’t modify data in production environments. For this we can ovveride the base template with a custom template.

https://github.com/dizballanze/django-admin-env-notice

Miscellaneous

If you have user groups and permissions, it is important to set permissions on object level.

When using ModelAdmin.get_urls() to extend urls, Django by default doesn’t do any permission checks and the view is accessible to public. Ensure that these views are secure by wrapping them with admin_view.

class BookAdmin(admin.ModelAdmin):
    def get_urls(self):
        urls = super().get_urls()
        book_urls = [
            path('book_char_data/', self.admin_site.admin_view(self.chart_data))
        ]
        return book_urls + urls

Final Words

This short book is written to customize admin interface so that custom views, reports, analytics are generated in the admin itself.

To provide feedback about this book, please write to chillar@avilpage.com.