Minimal Django theme based on Tailwind CSS supporting advanced sidebar customizations, color combinations, actions, filters ...

  • By Remastr
  • Last update: Nov 22, 2022
  • Comments: 14

Screenshot - Objects Listing

Screenshot - Login Page

Unfold Django Admin Theme

Unfold is a new theme for Django Admin incorporating some most common practises for building full-fledged admin areas.

  • Visual: provides new user interface based on Tailwind CSS framework
  • Sidebar: simplifies definition of custom sidebar navigation
  • Configuration: most of the basic options can be changed in settings.py
  • Dependencies: completely based only on django.contrib.admin
  • Filters: custom widgets for filters (e.g. numeric filter)
  • Actions: multiple ways how to define actions within different parts of admin

Table of Contents

Installation

The installation process is minimal. Everything what is needed after installation is to put new application at the beginning of INSTALLED_APPS. Default admin configuration in urls.py can stay as it is and there are no changes required.

# settings.py

INSTALLED_APPS = [
    "unfold",  # before django.contrib.admin
    "unfold.contrib.filters",  # optional
    "django.contrib.admin",  # required
]

In case you need installation command below are the versions for pip and poetry which needs to be executed in shell.

pip install django-unfold
poetry add django-unfold

Just for an example below is the minimal admin configuration in terms of adding Unfold into URL paths.

# urls.py

from django.contrib import admin
from django.urls import path

urlpatterns = [
    path("admin/", admin.site.urls),
    # Other URL paths
]

After installation, it is required that admin classes are going to inherit from custom ModelAdmin available in unfold.admin.

from django.contrib import admin
from unfold.admin import ModelAdmin


@admin.register(MyModel)
class CustomAdminClass(ModelAdmin):
    pass

Configuration

# settings.py

from django.templatetags.static import static
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _

UNFOLD = {
    "SITE_TITLE": None,
    "SITE_HEADER": None,
    "SITE_URL": "/",
    "SITE_ICON": lambda request: static("logo.svg"),
    "DASHBOARD_CALLBACK": "sample_app.dashboard_callback",
    "LOGIN": {
        "image": lambda r: static("sample/login-bg.jpg"),
        "redirect_after": lambda r: reverse_lazy("admin:APP_MODEL_changelist"),
    },
    "STYLES": [
        lambda request: static("css/style.css"),
    ],
    "SCRIPTS": [
        lambda request: static("js/script.js"),
    ],
    "COLORS": {
        "primary": {
            "50": "#FAF5FF",
            "100": "#F3E8FF",
            "200": "#E9D5FF",
            "300": "#D8B4FE",
            "400": "#C084FC",
            "500": "#A855F7",
            "600": "#9333EA",
            "700": "#7E22CE",
            "800": "#6B21A8",
            "900": "#581C87",
        },
    },
    "EXTENSIONS": {
        "modeltranslation": {
            "flags": {
                "en": "πŸ‡¬πŸ‡§",
                "fr": "πŸ‡«πŸ‡·",
                "nl": "πŸ‡§πŸ‡ͺ",
            },
        },
    },
    "SIDEBAR": {
        "show_search": False,  # Search in applications and models names
        "show_all_applications": False,  # Dropdown with all applications and models
        "navigation": [
            {
                "title": _("Navigation"),
                "separator": True,  # Top border
                "items": [
                    {
                        "title": _("Dashboard"),
                        "icon": "dashboard",  # Supported icon set: https://fonts.google.com/icons
                        "link": reverse_lazy("admin:index"),
                        "badge": "sample_app.badge_callback",
                    },
                    {
                        "title": _("Users"),
                        "icon": "people",
                        "link": reverse_lazy("admin:users_user_changelist"),
                    },
                ],
            },
        ],
    },
    "TABS": [
        {
            "models": [
                "app_label.model_name_in_lowercase",
            ],
            "items": [
                {
                    "title": _("Your custom title"),
                    "link": reverse_lazy("admin:app_label_model_name_changelist"),
                },
            ],
        },
    ],
}


def dashboard_callback(request, context):
    """
    Callback to prepare custom variables for index template which is used as dashboard
    template. It can be overridden in application by creating custom admin/index.html.
    """
    context.update(
        {
            "sample": "example",  # this will be injected into templates/admin/index.html
        }
    )
    return context


def badge_callback(request):
    return 3

Decorators

@display

Unfold introduces it's own unfold.decorators.display decorator. By default it has exactly same behavior as native django.contrib.admin.decorators.display but it adds same customizations which helps to extends default logic.

@display(label=True), @display(label={"value1": "success"}) displays a result as a label. This option fits for different types of statuses. Label can be either boolean indicating we want to use label with default color or dict where the dict is responsible for displaying labels in different colors. At the moment these color combinations are supported: success(green), info(blue), danger(red) and warning(orange).

@display(header=True) displays in results list two information in one table cell. Good example is when we want to display customer information, first line is going to be customer's name and right below the name display corresponding email address. Method with such a decorator is supposed to return a list with two elements return "Full name", "E-mail address".

# models.py

from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _

from unfold.admin import ModelAdmin
from unfold.decorators import display


class UserStatus(TextChoices):
    ACTIVE = "ACTIVE", _("Active")
    PENDING = "PENDING", _("Pending")
    INACTIVE = "INACTIVE", _("Inactive")
    CANCELLED = "CANCELLED", _("Cancelled")


class UserAdmin(ModelAdmin):
    list_display = [
        "display_as_two_line_heading",
        "show_status",
        "show_status_with_custom_label",
    ]

    @display(
        description=_("Status"),
        ordering="status",
        label=True,
        mapping={
            UserStatus.ACTIVE: "success",
            UserStatus.PENDING: "info",
            UserStatus.INACTIVE: "warning",
            UserStatus.CANCELLED: "danger",
        },
    )
    def show_status(self, obj):
        return obj.status

    @display(description=_("Status with label"), ordering="status", label=True)
    def show_status_with_custom_label(self, obj):
        return obj.status, obj.get_status_display()

    @display(header=True)
    def display_as_two_line_heading(self, obj):
        return "First main heading", "Smaller additional description"

Actions

Currently in Django admin it is possible to define one type of action for objects via actions attribute in ModelAdmin class. Action is then visible in the select dropdown. Theme introduces several other ways how to add different types of actions. Currently theme supports:

  • Global actions displayed at the top of results list
  • Row action displayed per row in results list
  • Detail action displayed when viewing object detail
  • Submit line action displayed near object detail submit button

Compared to Django action decorator, you can specify 2 more arguments:

  • url_path: Action path name
  • attrs: Dictionary of the additional attributes added to the <a> element.
# admin.py

from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin
from unfold.decorators import action


class UserAdmin(ModelAdmin):
    actions_list = ["changelist_global_action"]
    actions_row = ["changelist_row_action"]
    actions_detail = ["change_detail_action"]
    actions_submit_line = ["submit_line_action"]

    @action(description=_("Submit"))
    def submit_line_action(self, request, obj):
        pass

    @action(description=_("Global"), url_path="global-action")
    def changelist_global_action(self, request):
        pass

    @action(description=_("Row"), url_path="row-action")
    def changelist_row_action(self, request, object_id):
        pass

    @action(description=_("Detail"), url_path="detail-action")
    def change_detail_action(self, request, object_id):
        pass

    @action(
        description=_("Detail"),
        url_path="site-preview",
        attrs={"id": "preview", "target": "_blank"},
    )
    def site_preview(self, request, object_id):
        pass

Filters

By default, Django admin handles all filters as regular HTML links pointing at the same URL with different query parameters. This approach is for basic filtering more than enough. In the case of more advanced filtering by incorporating input fields, it is not going to work.

Currently, Unfold implements numeric filters inside unfold.contrib.filters application. In order to use these filters, it is required to add this application into INSTALLED_APPS in settings.py right after unfold application.

# admin.py

from django.contrib import admin
from django.contrib.auth.models import User

from unfold.admin import ModelAdmin
from unfold.contrib.admin import (
    RangeNumericFilter,
    SingleNumericFilter,
    SliderNumericFilter,
    RangeDateFilter,
    RangeDateTimeFilter,
)


class CustomSliderNumericFilter(SliderNumericFilter):
    MAX_DECIMALS = 2
    STEP = 10


@admin.register(User)
class YourModelAdmin(ModelAdmin):
    list_filter = (
        ("field_A", SingleNumericFilter),  # Numeric single field search, __gte lookup
        ("field_B", RangeNumericFilter),  # Numeric range search, __gte and __lte lookup
        ("field_C", SliderNumericFilter),  # Numeric range filter but with slider
        ("field_D", CustomSliderNumericFilter),  # Numeric filter with custom attributes
        ("field_E", RangeDateFilter),  # Date filter
        ("field_F", RangeDateTimeFilter),  # Datetime filter
    )

User Admin Form

User's admin in Django is specific as it contains several forms which are requiring custom styling. All of these forms has been inherited and accordingly adjusted. In user admin class it is needed to use these inherited form classes to enable custom styling matching rest of the website.

# models.py

from django.contrib.admin import register
from django.contrib.auth.models import User
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from unfold.admin import ModelAdmin
from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm


@register(User)
class UserAdmin(BaseUserAdmin, ModelAdmin):
    form = UserChangeForm
    add_form = UserCreationForm
    change_password_form = AdminPasswordChangeForm

Adding Custom Styles and Scripts

To add new custom styles, for example for custom dashboard, it is possible to load them via STYLES key in UNFOLD dict. This key accepts a list of strings or lambda functions which will be loaded on all pages. JavaScript files can be loaded by using similar apprach, but SCRIPTS is used.

# settings.py

from django.templatetags.static import static

UNFOLD = {
    "STYLES": [
        lambda request: static("css/style.css"),
    ],
    "SCRIPTS": [
        lambda request: static("js/script.js"),
    ],
}

Project Level Tailwind Stylesheet

When creating custom dashboard or adding custom components, it is needed to add own styles. Adding custom styles is described above. Most of the time, it is supposed that new elements are going to match with the rest of the administration panel. First of all, create tailwind.config.js in your application. Below is located minimal configuration for this file.

// tailwind.config.js

module.exports = {
    content: [
        "./your_project/**/*.{html,py,js}"
    ],
    // In case custom colors are defined in UNFOLD["COLORS"]
    colors: {
        primary: {
            100: "var(--color-primary-100)",
            200: "var(--color-primary-200)",
            300: "var(--color-primary-300)",
            400: "var(--color-primary-400)",
            500: "var(--color-primary-500)",
            600: "var(--color-primary-600)",
            700: "var(--color-primary-700)",
            800: "var(--color-primary-800)",
            900: "var(--color-primary-900)"
        }
    }
}

Once the configuration file is set, it is possible to compile new styles which can be loaded into admin by using STYLES key in UNFOLD dict.

npx tailwindcss  -o your_project/static/css/styles.css --watch --minify

Custom Admin Dashboard

The most common thing which needs to be adjusted for each project in admin is the dashboard. By default Unfold does not provide any dashboard components. The default dashboard experience with list of all applications and models is kept with proper styling matching rest of the components but thats it. Anyway, Unfold was created that creation of custom dashboard will be streamlined.

Create templates/admin/index.html in your project and paste the base template below into it. By default, all your custom styles here are not compiled because CSS classes are located in your specific project. Here it is needed to set up the Tailwind for your project and all requried instructions are located in Project Level Tailwind Stylesheet chapter.

{% extends 'unfold/layouts/base_simple.html' %}

{% load cache humanize i18n %}

{% block breadcrumbs %}{% endblock %}

{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}

{% block branding %}
    <h1 id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Django administration') }}</a></h1>
{% endblock %}

{% block content %}
    Start creating your own Tailwind components here
{% endblock %}

Note: In case that it is needed to pass custom variables into dashboard tamplate, check DASHOARD_CALLBACK in UNFOLD dict.

Unfold Development

Pre-commit

Before adding any source code, it is recommended to have pre-commit installed on your local computer to check for all potential issues when comitting the code.

pip install pre-commit
pre-commit install
pre-commit install --hook-type commit-msg

Poetry Configuration

To add a new feature or fix the easiest approach is to use django-unfold in combination with Poetry. The process looks like:

  • Install django-unfold via poetry add django-unfold
  • After that it is needed to git clone the repository somewhere on local computer.
  • Edit pyproject.toml and update django-unfold line django-unfold = { path = "../django-unfold", develop = true}
  • Lock and update via poetry lock && poetry update

Compiling Tailwind

At the moment project contains package.json with all dependencies required to compile new CSS file. Tailwind configuration file is set to check all html, js and py files for Tailwind's classeses occurrences.

npm install
npx tailwindcss -i styles.css -o src/unfold/static/unfold/css/styles.css --watch --minify

npm run watch

Some components like datepickers, calendars or selectors in admin was not possible to style by overriding html templates so their default styles are overriden in styles.css.

None: most of the custom styles localted in style.css are created via @apply some-tailwind-class;.

Github

https://github.com/remastr/django-unfold

Comments(14)

  • 1

    Breadcrumb don't have a valid link

    When I go to edit or create an item, the breadcrumb doesn't work properly, the link doesn't convert to a valid URL https://user-images.githubusercontent.com/4583818/202024165-e2fb2355-7892-4438-8238-04160e63b422.mov

  • 2

    select dom had duplicate "down arrow"

    image

    When I use unfold, I find that there are duplicate first arrows in the drop-down selection box, like the snapshot.

    I add a same className to fix by myself.

    <style>
      select:not([class*=bg-none]):not([multiple]) {
          -webkit-appearance: none; 
          -moz-appearance: none;
          appearance: none;
      }
    </style>
    
  • 3

    How do you setup the package?

    Hey guys, cool UI.

    Only thing is it seems really difficult to setup.

    Tried using it but the UI doesn't change and getting an error in the admin panel: Screen Shot 2022-10-06 at 12 18 50 PM

    Installed it and added it to the installed_apps section but it seems like it's missing a lot of steps in the readme...

  • 4

    CharField does not expand proportionally to its content

    Vertical expansion in admin is not working as expected. When there are many lines of text, the area will not expand.

    18ea5343-50bf-486f-90d2-40ee8e20de9f

    Configs

    version_info = forms.CharField(
        required=False,
        label=_("Version info"),
        widget=UnfoldAdminTextInputWidget(),
    )
    

    It's not working without widget nor with UnfoldAdminTextareaWidget or UnfoldAdminTextInputWidget

  • 5

    "Close" button is not styled correctly

    When you disable change permission in admin, "Close" button is not correctly styled. I used this command:

    def has_change_permission(self, request, obj=None):
        return False
    

    5c07fd48-38b3-4d4b-a213-574e41812014

  • 6

    How to load js file use static by defer?

    I have a custom frontend js-component framework by alipinejs ,also build with tailwind

    But load the js-componet js file must be after load the alipinejs js file.

    It just add "defer" attribute into js-component js file tag in HTML

    So,how to load js file use static by defer on settings, this ⬇ image

    image

  • 7

    Django 4 Support -- Numeric Filters

    Python 3.10 Django 4.0.7

    In contrib/numeric_filters/forms.py this import -> from django.utils.translation import ugettext_lazy as _

    needs to be updated to from django.utils.translation import gettext_lazy as _ to be compatible with Django 4.0

    https://stackoverflow.com/questions/71420362/django4-0-importerror-cannot-import-name-ugettext-lazy-from-django-utils-tra

  • 8

    List filter based on annotation

    Before filters update (version 0.1.13) we could use following filter (filtering based on annotation):

    class ItemsNumberFilter(RangeNumericFilter):
        parameter_name = "items_count"
        title = _("items")
    
    
    @admin.register(Test)
    class TestAdmin(ModelAdmin):
    	...
        list_filter = (ItemsNumberFilter,)
    
        def get_queryset(self, request):
            return (
                super()
                .get_queryset(request)
                .annotate(
                    items_count=Count("item", distinct=True),
                )
            )
    

    After the filters update this raises an error:

    The value of 'list_filter[0]' must not inherit from 'FieldListFilter'
    

    We also cannot use the way of using filters showed in docs:

    list_filter = (
        ("items_count", ItemsNumberFilter)
    )
    

    Because we want to use annotation and not model field so it results in error:

    Test has no field named 'items_count'
    
  • 9

    chore(deps): bump django from 3.2.15 to 3.2.16

    Bumps django from 3.2.15 to 3.2.16.

    Commits
    • 4c85bec [3.2.x] Bumped version for 3.2.16 release.
    • 5b6b257 [3.2.x] Fixed CVE-2022-41323 -- Prevented locales being interpreted as regula...
    • 33affaf [3.2.x] Added stub notes 3.2.16 release.
    • 777362d [3.2.x] Added CVE-2022-36359 to security archive.
    • eb5bdb4 [3.2.x] Post-release version bump.
    • See full diff in compare view

    Dependabot compatibility score

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


    Dependabot commands and options

    You can trigger Dependabot actions by commenting on this PR:

    • @dependabot rebase will rebase this PR
    • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
    • @dependabot merge will merge this PR after your CI passes on it
    • @dependabot squash and merge will squash and merge this PR after your CI passes on it
    • @dependabot cancel merge will cancel a previously requested merge and block automerging
    • @dependabot reopen will reopen this PR if it is closed
    • @dependabot close will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
    • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    • @dependabot use these labels will set the current labels as the default for future PRs for this repo and language
    • @dependabot use these reviewers will set the current reviewers as the default for future PRs for this repo and language
    • @dependabot use these assignees will set the current assignees as the default for future PRs for this repo and language
    • @dependabot use this milestone will set the current milestone as the default for future PRs for this repo and language

    You can disable automated security fix PRs for this repo from the Security Alerts page.

  • 10

    Bugfix/multiple values for display with label

    Providing something other than dict to label in display decorator caused TypeError by isinstance check. Therefore multiple did not work correctly. Fixed it by checking whether the value is list or tuple. Now the list values will be displayed as separate "tiles" in admin.

    Also updated the documentation since mappings attribute does not exist anymore in display and it was merged into label attribute.

  • 11

    chore(deps): bump django from 3.2.14 to 3.2.15

    Bumps django from 3.2.14 to 3.2.15.

    Commits
    • 653a7bd [3.2.x] Bumped version for 3.2.15 release.
    • b3e4494 [3.2.x] Fixed CVE-2022-36359 -- Escaped filename in Content-Disposition header.
    • cb7fbac [3.2.x] Fixed collation tests on MySQL 8.0.30+.
    • 840d009 [3.2.x] Fixed inspectdb and schema tests on MariaDB 10.6+.
    • a5eba20 Adjusted release notes for 3.2.15.
    • ad104fb [3.2.x] Added stub release notes for 3.2.15 release.
    • 22916c8 [3.2.x] Fixed RelatedGeoModelTest.test08_defer_only() on MySQL 8+ with MyISAM...
    • e1cfbe5 [3.2.x] Added CVE-2022-34265 to security archive.
    • 605cf0d [3.2.x] Post-release version bump.
    • See full diff in compare view

    Dependabot compatibility score

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


    Dependabot commands and options

    You can trigger Dependabot actions by commenting on this PR:

    • @dependabot rebase will rebase this PR
    • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
    • @dependabot merge will merge this PR after your CI passes on it
    • @dependabot squash and merge will squash and merge this PR after your CI passes on it
    • @dependabot cancel merge will cancel a previously requested merge and block automerging
    • @dependabot reopen will reopen this PR if it is closed
    • @dependabot close will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
    • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    • @dependabot use these labels will set the current labels as the default for future PRs for this repo and language
    • @dependabot use these reviewers will set the current reviewers as the default for future PRs for this repo and language
    • @dependabot use these assignees will set the current assignees as the default for future PRs for this repo and language
    • @dependabot use this milestone will set the current milestone as the default for future PRs for this repo and language

    You can disable automated security fix PRs for this repo from the Security Alerts page.

  • 12

    Doc request: give example of action

    It would be helpful (for beginners like me) to see a code example of an action. Esp because the action behaves differently from the standard Django admin actions (which are also not documented extensively).

    I had trouble creating a detail page action. It was not clear what to return from the action. This now works, but now I'm trying to find out how to integrate the view with the Unfold templates. IOW what templates to extend.

    TIA!

  • 13

    Cursor moved in search field

    A tiny usability issue: when one adds a search field everything works as expected. However when executing the search and the page gets refreshed the cursor is moved to the beginning of the target text in the search field. So when one wants to edit or clear the target text one has to manually move the cursor to the end of the target text.

    Additionally, a button to clear the search box would be nice.

    (Using Safari)

  • 14

    Summernote fields are not displayed correctly

    When you disable change permission in admin via this command:

    def has_change_permission(self, request, obj=None):
        return False
    

    Summernote fields are displayed with HTML tags and without summernote widget/form.

    95dac518-6b36-47ae-8112-9303ae49cbb0