Baton: a cool, modern and responsive Django admin application based on Bootstrap 5
Tags: django, bootstrap, ui, tutorial
Published: 2021-03-22 15:07:26

I must admit, one of the features that made me immediately fall in love with Django was the built-in admin application. Coming from a PHP framework, constructing all the CRUD stuff by hand, application after application, and suddenly having all this stuff autogenerated was a big deal.

The problem is, **the Django default admin application doesn't look really cool**, does it? It surely does its job and includes the most important features, but I'm not sure it could be delivered to a client without tweaking it. I mean, almost every web application framework or CMS has a pretty admin area, and people are somewhat used to something similar.

Here at [Otto srl](https://www.otto.to.it) we (also) develop web applications, and we do that from scratch using the Django framework. We tend to use the Django admin application to provide access to data whenever possible.
We used to adopt [django-suit](https://github.com/darklow/django-suit) as admin application because it really looked nice. The problem is that django-suit was based on bootstrap 2 and even if **darklow** started developing a v2 based on bootstrap 4, such version never really come to life.

That's why we decided to develop our own Django admin application, and that's the beginning of Django Baton. **The project started in February 2017**, so it turned 4 years old this year :).
We use it extensively in every web application, we really have many projects deployed in production using it and that's why we continue its development and fix bugs quite quickly.

## Django Baton
Baton is a cool, modern and responsive Django admin application based on Bootstrap 5.

It was built with one concept in mind: **override as few Django stuff as possible**.

If you're wondering why the answer is: in order to stay as much compatible with the default Django admin.
We are quite satisfied with the default admin app features, we just want it to be more appealing. Not overriding things is also a good way to prevent bugs. Baton can work with every Django/python version without too much effort, and only recently, in v2, we dropped support for `Django < 2.1`, and it was by design, because boys, it's time to abandon python 2 and move on.

### First things first
- You can try it. There's a live demo available [here](https://django-baton.sqrt64.it/).
- It's MIT licensed and the repo project is available [here](https://github.com/otto-torino/django-baton).
- The documentation covers every aspect of the application, and it's available [here](https://django-baton.readthedocs.io/en/latest/).

### Features

- Based on Bootstrap 5 and FontAwesome Free 5
- Fully responsive (really)
- Custom and flexible sidebar menu
- Configurable search field
- Text input filters and dropdown list filters facilities
- Form tabs out of the box
- Easy way to include templates in the change form and change list pages
- Easy way to add attributes to change list table rows/cells
- Collapsable stacked inline entries
- Lazy loading of uploaded images
- Optional display of changelist filters in a modal
- Optional use of changelist filters as a form (combine some filters at once and perform the search action)
- Optional index page filled with google analytics widgets
- Customization available recompiling the js app provided
- Supports custom translations

### Getting started

You can install Baton directly from pip:

``` bash
$ pip install django-baton
```

Add `baton` and `baton.autodiscover` to your `INSTALLED_APPS`:

``` python
INSTALLED_APPS = (
    # ...
    'baton', # place it before django.contrib.admin
    'django.contrib.admin',
    # ...
    'baton.autodiscover', # place it at the very end
)
```

Replace `django.contrib.admin` in your project urls, and add Baton urls:

``` python
# from django.contrib import admin
from baton.autodiscover import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('baton/', include('baton.urls')),
]
```

You need to add both `baton` and `baton.autodiscover` to your `INSTALLED_APPS` in order to avoid registering all applications directly to Baton. Baton autodiscover module will run last and will detect all the applications registered to the default admin and make use of them. This way you can easily switch from one admin application to another one, and you can easily integrate Baton into any existing project.

### Configuration
Let's see a full configuration example:

``` python
# in your settings.py
BATON = {
    'SITE_HEADER': 'Baton',
    'SITE_TITLE': 'Baton',
    'INDEX_TITLE': 'Site administration',
    'SUPPORT_HREF': 'https://github.com/otto-torino/django-baton/issues',
    'COPYRIGHT': 'copyright © 2020 <a href="https://www.otto.to.it">Otto srl</a>', # noqa
    'POWERED_BY': '<a href="https://www.otto.to.it">Otto srl</a>',
    'CONFIRM_UNSAVED_CHANGES': True,
    'SHOW_MULTIPART_UPLOADING': True,
    'ENABLE_IMAGES_PREVIEW': True,
    'CHANGELIST_FILTERS_IN_MODAL': True,
    'CHANGELIST_FILTERS_ALWAYS_OPEN': False,
    'CHANGELIST_FILTERS_FORM': True,
    'MENU_ALWAYS_COLLAPSED': False,
    'MENU_TITLE': 'Menu',
    'MESSAGES_TOASTS': False,
    'GRAVATAR_DEFAULT_IMG': 'retro',
    'LOGIN_SPLASH': '/static/core/img/login-splash.png',
    'SEARCH_FIELD': {
        'label': 'Search contents...',
        'url': '/search/',
    },
    'MENU': (
        { 'type': 'title', 'label': 'main', 'apps': ('auth', ) },
        {
            'type': 'app',
            'name': 'auth',
            'label': 'Authentication',
            'icon': 'fa fa-lock',
            'models': (
                {
                    'name': 'user',
                    'label': 'Users'
                },
                {
                    'name': 'group',
                    'label': 'Groups'
                },
            )
        },
        { 'type': 'title', 'label': 'Contents', 'apps': ('flatpages', ) },
        { 'type': 'model', 'label': 'Pages', 'name': 'flatpage', 'app': 'flatpages' },
        { 'type': 'free', 'label': 'Custom Link', 'url': 'http://www.google.it', 'perms': ('flatpages.add_flatpage', 'auth.change_user') },
        { 'type': 'free', 'label': 'My parent voice', 'default_open': True, 'children': [
            { 'type': 'model', 'label': 'A Model', 'name': 'mymodelname', 'app': 'myapp' },
            { 'type': 'free', 'label': 'Another custom link', 'url': 'http://www.google.it' },
        ] },
    ),
    'ANALYTICS': {
        'CREDENTIALS': os.path.join(BASE_DIR, 'credentials.json'),
        'VIEW_ID': '12345678',
    }
}
```
I won't describe here each available setting, they're all well explained in the [documentation](http://django-baton.readthedocs.io/) or in the [repo page](https://github.com/otto-torino/django-baton).

But I'd like to explore here in more detail some of the coolest features which Baton provides. 

### Login splash

As shown in the cover image of this post, you can easily customize the login page providing an image background, just use the `LOGIN_SPLASH` option.

### Sidebar

![Screenshot from 2021-03-22 10-41-03](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sn3x6746ftvpkmmj2ttc.png)

The sidebar is very handy. True, the last Django version comes with a default sidebar menu in its admin app but is not very configurable and I don't like it very much.
I think Baton does a good job here, providing a nice and extremely configurable sidebar menu to your application. Baton also provides a search field widget, that can be very powerful, more on it later.

Baton sidebar is visible by default on desktop devices, but you can decide to collapse it (see `MENU_ALWAYS_COLLAPSED` option) and make it accessible through a dedicated icon in the top bar if you need more space for your data (same behaviour as for mobile devices).
Also, the user area can be collapsed to save some space in the sidebar, see the `COLLAPSABLE_USER_AREA` option.

Baton menu system offers four kinds of menu items: **title**, **app**, **model** and **free**. I won't cover here all the possible configuration since this is explained in detail in the documentation, but let's say that:

- **title items** can be used to group other items
- **app items** lets you create a menu iten for an application, whose children items are all the models of the app (but you can customize the children prop)
- **model items** lets you create a menu item for a model
- **free items** can be used to create any other kind of menu items (you can provide an URL, an icon and a regular expression to indicate when an item should be considered active, the match is calculated against the URL path).

What follows is an example of the menu configuration.

``` python
help_modal = '''
modalContent = `
    <div>
        <p><strong style="font-weight: 900">Welcome to django-baton!</strong></p>
    </div>
`;
modal = new Baton.Modal({
    title: 'Help',
    content: modalContent,
});
modal.open();
'''

BATON = {
    'MENU': (
        { 'type': 'title', 'label': 'System', 'apps': ('auth', ) },
        {
            'type': 'model',
            'app': 'auth',
            'name': 'user',
            'label': 'Users',
            'icon': 'fa fa-user',
        },
        {
            'type': 'model',
            'app': 'auth',
            'name': 'group',
            'label': 'Groups',
            'icon': 'fa fa-users',
        },
        { 'type': 'title', 'label': 'Contents', 'apps': ('kitchensink', ) },
        {
            'type': 'model',
            'app': 'kitchensink',
            'name': 'news',
            'label': 'News',
            'icon': 'fa fa-newspaper',
        },
        { 'type': 'title', 'label': 'Utilitites', 'apps': () },
        {
            'type': 'free',
            'url': 'javascript:%s' % help_modal,
            'label': 'Help',
            'icon': 'fa fa-life-ring',
        },
        {
            'type': 'free',
            'url': 'https://www.otto.to.it',
            'label': 'Otto',
            'icon': 'fa fa-grin-stars',
        },
    ),
}

```
As you can see, you can use `title` items to group menu items and you can use the `app` keyword to set the group visibility so that users which cannot act on children will not see the group entirely.

You can use free voices to add custom URLs or even to trigger some javascript function.

#### The search field

The last Baton release added support for a **full-text search component in the sidebar**. Baton provides the component and the autocomplete feature (suggest as you type), while you should provide your **backend** implementation. This way you can decide which contents to filter and which results to provide given a search text.

![Screenshot from 2021-03-22 12-04-49](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i7u8lz9a2ky5wycos4sm.png) 

You need to configure it this way:

``` python
BATON = {
  'SEARCH_FIELD': {
    'label': 'Label shown as placeholder',
    'url': '/api/path/',
  },
}
```

The `label` will be shown as a placeholder for the input field, so you can set it to something like "search blog and news...", while the `url` parameter is the endpoint for your backend implementation.

The backend implementation should return the search result in a format that Baton can understand:

``` json
{
    "length": 2,
    "data": [
        { "label": "My result #1", icon: "fa fa-edit", "url": "/admin/myapp/mymodel/1/change" },
        // ...
    ]
}
```

That's simply a JSON. You should provide a `length` attribute that represents the number of results and an array of results collected in the `data` attribute. Each result should be an object containing a `label` and an `url` properties. Optionally you can also define an `icon` property.

While the user types some chars in the fieldset, Baton will call your endpoint passing the inserted text and asking for results. Results are then presented to the user. A click on a result will cause the browser to follow the associated URL. The user can move between results also using the keyboard arrows and select a result hitting the return key.

Let's see a backend implementation example:

``` python
@staff_member_required
def admin_search(request):
    text = request.GET.get('text', None)
    res = []
    news = News.objects.all()
    if text:
        news = news.filter(title__icontains=text)
    for n in news:
        res.append({
            'label': str(n) + ' edit',
            'url': '/admin/news/news/%d/change' % n.id,
            'icon': 'fa fa-edit',
        })
    if text.lower() in 'Lucio Dalla Wikipedia'.lower():
        res.append({
            'label': 'Lucio Dalla Wikipedia',
            'url': 'https://www.google.com',
            'icon': 'fab fa-wikipedia-w'
        })
    return JsonResponse({
        'length': len(res),
        'data': res
    })
```
In this case, we're searching on our `News` model, adding also the possibility to search for a wiki page. As you can see you're not restricted to display results tied to your models, you can literally send the user everywhere!

### Jacascript signals

Baton tries to avoid overriding templates and views. All it's styled through CSS and when more control is needed, javascript is used in order to adjust the DOM. That means that Baton performs DOM manipulation at startup, and there may be situations in which you need to know when it finishes its job. 

For this reason, Baton provides an easy way to attach listeners that will be notified when some event occurs:

- **onNavbarReady**: dispatched when the navbar is fully rendered
- **onMenuReady**: dispatched when the menu is fully rendered (probably the last event fired since the menu contents are retrieved async)
- **onTabsReady**: dispatched when the change form tabs are fully rendered
- **onMenuError**: dispatched if the request sent to retrieve menu contents fails
- **onReady**: dispatched when Baton JS has finished its sync job

You can then attach your listener this way:

``` javascript
Baton.Dispatcher.register('onMenuReady', function () { console.log('BATON MENU IS READY') })
```

**Where to put this code?**

You need to override the `admin/base_site.html` template. This is something I always do.

**How?**

Just define an app before `baton` in the `INSTALLED_APPS`, let's call it `core` for example. Create a template `core/templates/admin/base_site.html` and just copy the code of the `baton/templates/admin/base_site.html`. Then you can add your own stuff.

### Other javascript utilities

Baton uses javascript a lot to provide its features. So it also provides some useful classes in its namespace:

- **Dispatcher**: a mediator pattern dispatcher you can use to emit and subscribe to events.
- **Modal**: a modal class that lets you use bootstrap modals without dealing with all the markup.

You can read more about them and see some examples in the [documentation](https://django-baton.readthedocs.io/en/latest/js_utilities.html).

### Translations
Baton does not override Django stuff, you already know that. So it almost doesn't define new strings. Almost, because some strings are needed and used here and there and cannot be retrieved from the page content. Baton comes with `en` and `it` translations out of the box, but you can add your locale translations, and even change the default strings simply by overriding Baton `translation` property:

``` javascript
Baton.translations = {
  unsavedChangesAlert: 'You have some unsaved changes.',
  uploading: 'Uploading...',
  filter: 'Filter',
  close: 'Close',
  save: 'Save',
  search: 'Search',
  cannotCopyToClipboardMessage: 'Cannot copy to clipboard, please do it manually: Ctrl+C, Enter',
  retrieveDataError: 'There was an error retrieving the data'
}
```
Since you'll put this code inside a template you'll be able to use every `i18n` template tag.

### Include, include and yes, include

This feature was added to please earlier Baton users. Its a feature coming from **django-suit**, something that I've never used so much, but many developers did.

Baton uses the `template` HTML tag to inject contents inside the document. All this functionalities are well documented so I'll simply link the documentation page for each of them:

- [Change list include](https://django-baton.readthedocs.io/en/latest/changelist_includes.html) 
- [Change list filters include](https://django-baton.readthedocs.io/en/latest/changelist_filters_includes.html)    
- [Change form includes](https://django-baton.readthedocs.io/en/latest/form_includes.html)

And then, there is one more cool "include" functionality, that lets you inject attributes in the change list table rows. This is nice, because you can inject CSS classes (`.table-info`, `.table-success`) and attributes (`title` will show its content on a tooltip on mouseover). And most important, you can inject this stuff inside a table row (`tr`), a single table cell (`td`) or even some other custom element making use of the `selector` and `getParent` properties. All this is [well documented](https://django-baton.readthedocs.io/en/latest/changelist_row_attributes.html).

### Form tabs

![Screenshot from 2021-03-22 12-39-46](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dtodw70hl67erwbmxogq.png)
 
If you used **django-suit** you loved its tab system. Forms become just too much cleaner using them that I just cannot think any more of a change form without this feature. For this reason, Baton had support for form tabs since its first days. And it brings tab support even further, allowing to group fieldsets and inlines, and to change tabs ordering.

Everything is done just by adding some well-defined CSS classes to your fieldsets.

Take a look at the [documentation](https://django-baton.readthedocs.io/en/latest/form_tabs.html) for a complete explanation of all the rules. Keep in mind that you can also open a tab by default just by appending the right hash to an URL.

### Change list filters
A few words about the change list filters. They're cool but not always perfect in the default admin. I mean, sometimes the FK filtering is a mess because you have too many entries. Sometimes you need to perform a text search. Sometimes there are so many filters you may desire to have them in a different place.

Baton adds some juice to your admin filters:
- [Input text filters](https://django-baton.readthedocs.io/en/latest/list_filters.html#input-text-filters): add simple text filters (something like a `search_fields` but restricted to one field).
- [Dropdown filters](https://django-baton.readthedocs.io/en/latest/list_filters.html#dropdown-filters): when the related entries grow too much, just avoid having an infinite unordered list and use dropdowns.
- [Filters in modal](https://django-baton.readthedocs.io/en/latest/configuration.html#changelist-filters-in-modal): I like this one: open your list filters in a modal above the document. It's clean.
- [Filters in modal](https://django-baton.readthedocs.io/en/latest/configuration.html#changelist-filters-in-modal): I like this one: open your list filters on a modal above the document. It's clean.
- [Filters form](https://django-baton.readthedocs.io/en/latest/configuration.html#changelist-filters-form): by default, you can apply one filter at a time. That can be cumbersome, and there are times when you prefer to tweak some filters at once and then perform the search action. Using this option Baton will transform your list filters in a form with a submit button: adjust all your filters and submit them all together.

### And more...

There are many more features that Baton provides:
- [Google analytics dashboard](https://django-baton.readthedocs.io/en/latest/configuration.html#analytics) out of the box
- [Images preview](https://django-baton.readthedocs.io/en/latest/configuration.html#enable-images-preview)
- [Multipart uploading undetermined progress](https://django-baton.readthedocs.io/en/latest/configuration.html#show-multipart-uploading)
- [Unsaved changes confirmation](https://django-baton.readthedocs.io/en/latest/configuration.html#confirm-unsaved-changes)
- ...

Take your time to surf through the documentation, you'll find everything explained.

### Customization

This has been a long journey, I'd like just to spend a few more words about Baton customization.

Baton comes with an arguably neutral palette, but there are situations in which even the administrative area should be heavily customized in order to reflect the brand, and changing the logo is not enough.

We've seen that Baton does all its job through styling and javascript. That's true, and in fact, Baton core it's a modern js application injected in the `base_site.html` admin template.

Baton uses webpack to build the javascript application which also includes all the CSS. For this reason, it's very simple to customize how it looks. The Baton core app is nothing more that a static served content that obeys to the same rules of all other static resources: the first installed app which serves that content wins.  

That means that you can serve a customized version of the baton core app from an application defined before `baton` in your installed apps.

**But how can you create a customized version of Baton core app?**

Easy.

First: clone the Baton repository:

``` bash
$ git clone https://github.com/otto-torino/django-baton.git
```

Cd into the core app folder and install all dependencies:

``` bash
$ cd django-baton/baton/static/baton/app/
$ npm install
```  

Now you can make your modifications, in particular, you may want to change the font, the colours, or any bootstrap configurable variable. That's easy, just edit the `src/styles/_variables.scss` file. You can override any bootstrap variable in place!

Of course, you can see your changes live: Baton comes with a webpack dev server ready to be started. It's necessary to change the path of the included Baton app in the `base_site.html` template. The default Baton template already have the right path commented out, you just need to switch the two script tags:

``` HTML
<!-- <script src="{% static 'baton/app/dist/baton.min.js' %}"></script> comment the compiled src and uncomment the webpack served src -->
<script src="http://localhost:8080/dist/baton.min.js"></script>
``` 

Now you can see the changes live, and after editing and saving a source file the page refreshes.

When you're satisfied with your job you can compile your app:

``` bash
npm run compile
```

And finally you can copy the generated build in your custom app defined before `baton` (remember also to switch again the `script` tags in your `base_site.html` template):

``` bash
cp dist/baton.min.js ROOTAPP/static/baton/app/dist/
```

That's it! 

## Conclusion

Baton is a Django application that aims to provide a better admin experience without changing too much stuff.

I would say that using Baton you're still using Django admin application, it feels Django, but at the same time it has a cooler face and some helpful additions which can help you design a great UI/UX.

The project is completely open-source, each contribution is welcome and if you like it, please ★ star it on GitHub!