One of Django's strong points is its Admin interface. It really helps in getting projects off the ground faster since most web applications are composed of two parts: the user facing part and the admin interface. Django Admin practically makes the second one really easy. However, to unlock its full potential, we need to dive deeper into customization.
In this article, we will focus on customizing the Admin for a simple Article
model,
walking through various methods to build a more functional and user-friendly admin view.
From adjusting the layout and form fields to adding custom actions, filters, and inline editing,
we’ll explore how to tailor Django Admin to fit your project's specific requirements and enhance the experience for both developers and administrators.
Here's our starting Article
model:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | class Article(models.Model): class Status(models.TextChoices): DRAFT = "DRAFT", "Draft" PENDING_REVIEW = "PENDING_REVIEW", "Pending Review" PUBLISHED = "PUBLISHED", "Published" TRASH = "TRASH", "Trash" title = models.CharField("Title", max_length=256) body = models.TextField("Body") category = models.ForeignKey( Category, null=True, blank=True, on_delete=models.SET_NULL ) author = models.ForeignKey( UserModel, null=True, blank=True, on_delete=models.SET_NULL ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.DRAFT ) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) published = models.DateTimeField(null=True, blank=True) def publish(self, user): if not user.has_perm("blog.publish_article", self): raise PermissionDenied() self.status = Article.Status.PUBLISHED self.published = now() self.save() |
Customizing the Django Admin List View
Let's start by playing with the most common configs:
Basic Admin List View Customization
Here are the basic customization elements for the Article
Django Model:
1 2 3 4 5 6 7 8 9 10 11 12 | @admin.register(Article) class ArticleAdmin(ModelAdmin): list_display = ('title', 'category', 'author', 'status') list_filter = ( 'author', 'category', 'published', 'status', 'author__role' ) list_per_page = 10 ordering = ("-published", ) |
Here's the quick breakdown:
list_display
- Choose what columns to displaylist_filter
- Choose what filters to display on the right sidelist_per_page
- How many rows to display per pageordering
- How is the list ordered
Notice how author__role
is a field on the related User
model.
Here's how our Article
admin interface looks so far:
Adding Search to Django Admin
Adding search functionality to Django Admin is easy-peasy, we just need to specify on which fields to perform the search:
1 2 3 4 5 6 7 8 9 10 11 12 | @admin.register(Article) class ArticleAdmin(ModelAdmin): list_display = ('title', 'category', 'author', 'status') list_filter = ( 'author', 'category', 'published', 'status', 'author__role' ) list_per_page = 10 search_fields = ('title', 'body') # <-- |
Notice the new search field up top:
Display computed fields in list view
Sometimes we want to display data that is not explicitly available on the model. Sometimes these are called computed fields. As an example, let's compute the number of articles the author of an article has written. Here's how to do that:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | @admin.register(Article) class ArticleAdmin(ModelAdmin): list_display = ( 'title', 'category', 'author', 'status', 'author_article_count' ) list_filter = ( 'author', 'category', 'published', 'status', 'author__role' ) list_per_page = 10 search_fields = ('title', 'body') def author_article_count(self, obj): # <-- return Article.objects.filter(author=obj.author).count() author_article_count.short_description = "Author Experience" |
Check out our new column:
Notice how we also attached a short_description
to the method.
This will be the column header in the list view.
If not defined, the name of the method will be used.
Date Hierarchy
Another type of cool filtering we can add is a date hierarchy filter. It is used to select the year, then narrow down to the month and then day. This would be useful if we want to see what articles have been published in a certain date or period.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @admin.register(Article) class ArticleAdmin(ModelAdmin): list_display = ( 'title', 'category', 'author', 'status', 'author_article_count' ) list_filter = ( 'author', 'category', 'published', 'status', 'author__role' ) list_per_page = 10 search_fields = ('title', 'body') date_hierarchy = 'published' # <-- def author_article_count(self, obj): return Article.objects.filter(author=obj.author).count() author_article_count.short_description = "Author Experience" |
Inline editable fields
For quick edits, we might want to add inline editable fields in the admin. For example if we want to move an article to a different category or change its status. Here's how we do that:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @admin.register(Article) class ArticleAdmin(ModelAdmin): list_display = ( 'title', 'category', 'author', 'status', 'author_article_count' ) list_filter = ( 'author', 'category', 'published', 'status', 'author__role' ) list_per_page = 10 search_fields = ('title', 'body') date_hierarchy = 'published' list_editable = ( # <-- 'category', 'status', ) def author_article_count(self, obj): return Article.objects.filter(author=obj.author).count() author_article_count.short_description = "Author Experience" |
The result:
Custom Admin Actions
When deleting models from the Django Admin list view, you might have noticed that you select the Delete
action from a dropdown. We can create our own Custom Admin Actions and have them included in that dropdown.
For our purpose, we are going to create a publish action for our Article
model:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | @admin.action(description="Publish Articles") def publish_article(modeladmin, request, queryset): for article in queryset: try: article.publish(request.user) messages.success( request, f"You successfully published {article}" ) except PermissionDenied: messages.error( request, f"You do not have permission to publish {article}" ) @admin.register(Article) class ArticleAdmin(ModelAdmin): list_display = ( 'title', 'category', 'author', 'status', 'author_article_count' ) list_filter = ( 'author', 'category', 'published', 'status', 'author__role' ) list_per_page = 10 search_fields = ('title', 'body') date_hierarchy = 'published' list_editable = ( 'category', 'status', ) def author_article_count(self, obj): return Article.objects.filter(author=obj.author).count() author_article_count.short_description = "Author Experience" actions = [publish_article] |
Customizing the Django Admin Detail View
Autocomplete SlugField in Django Admin
A neat little trick, although of limited use, is to have an article's slug autocomplete as you write the article's title.
This is as simple as using the prepopulated_fields
feature:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | @admin.register(Article) class ArticleAdmin(ModelAdmin): list_display = ( 'title', 'category', 'author', 'status', 'author_article_count' ) list_filter = ( 'author', 'category', 'published', 'status', 'author__role' ) list_per_page = 10 search_fields = ('title', 'body') date_hierarchy = 'published' list_editable = ( 'category', 'status', ) prepopulated_fields = {"slug": ("title", )} # <-- def author_article_count(self, obj): return Article.objects.filter(author=obj.author).count() author_article_count.short_description = "Author Experience" actions = [publish_article] |
While you type in the title, the SlugField
is autocompleted. Might prove useful.
Grouping fields in fieldsets
This one has mostly to do with aesthetics.
In order to make life easier for your users, you might consider placing the fields in the detail view in logical groups.
Here's an example of how we would do that with our Article
model:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | @admin.register(Article) class ArticleAdmin(ModelAdmin): list_display = ( 'title', 'category', 'author', 'status', 'author_article_count' ) list_filter = ( 'author', 'category', 'published', 'status', 'author__role' ) list_per_page = 10 search_fields = ('title', 'body') date_hierarchy = 'published' list_editable = ( 'category', 'status', ) prepopulated_fields = {"slug": ("title", )} fieldsets = [ ( "Content", { "fields": [ "title", "body" ], }, ), ( "Meta", { "classes": ["collapse"], "fields": [ "category", "author", "status", "slug", "published" ], }, ), ] def author_article_count(self, obj): return Article.objects.filter(author=obj.author).count() author_article_count.short_description = "Author Experience" actions = [publish_article] |
This can lead to cleaner interfaces. It is a good idea to hide fields that are less used and provide a clutter-free experience for the end/user. Here is how it looks with the second formset collapsed:
Here is how it looks with the second formset expanded:
Overriding form field widgets
There are times when we want to choose a different widget for a field that the default one provided by Django.
The most common example is Select Widget vs Radio Group widget.
Let's check how we can override the widget of the category
field:
1 2 3 4 5 6 7 8 9 | @admin.register(Article) class ArticleAdmin(ModelAdmin): # ... formfield_overrides = { models.ForeignKey: {'widget': RadioSelect()}, } # ... |
As you can probably notice, the problem with this method is that it overrides all ForeignKey
widgets.
That might not always be what we want. If we want to change the widget of a specific field, we need to change the underlying
ModelForm
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class ArticleAdminForm(forms.ModelForm): class Meta: model = Article fields = '__all__' widgets = { 'category': forms.RadioSelect, } @admin.register(Article) class ArticleAdmin(ModelAdmin): form = ArticleAdminForm # ... |
Django Inline Admin Models
If you want to embed related models in the Django Admin, we can do so via TabularInline
ot StackedInline
classes. Here's how we would embed the Comment
model in the Article
admin:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | class CommentTabularInlineAdmin(admin.TabularInline): fields = ( 'text', 'author', 'created' ) model = Comment show_change_link = True extra = 1 @admin.register(Article) class ArticleAdmin(ModelAdmin): # ... inlines = (CommentTabularInlineAdmin, ) # <-- # ... |
Widgets: filter_horizontal vs filter_vertical
This feature has to do with how ManyToManyField
is displayed in the Django Admin Detail View.
The default way is a simple HTML <select multiple="multiple"> ... </select>
widget.
Suppose we add a Tag
model and link it as a ManyToManyField
to the Article
model:
1 2 3 4 5 | class Tag(models.Model): name = models.CharField("name", max_length=32) def __str__(self): return self.name |
1 2 3 4 5 6 7 | class Article(RulesModel): # ... tags = models.ManyToManyField("Tag") # ... |
Since we used fieldsets
, remember to also add the field to ArticleAdmin
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | @admin.register(Article) class ArticleAdmin(ModelAdmin): # ... fieldsets = [ # ... ( "Meta", { "classes": ["collapse"], "fields": [ "category", "tags", # <-- "author", "status", "slug", "published" ], }, ), ] |
Depending on how you have your models and fields structured, using the multi select might be annoying.
Django provides some useful javascript widgets to solve this problem. They work exactly the same, the only difference
being the orientation. We just need to add the ManyToManyField
to either the filter_horizontal
or
filter_vertical
list.
1 2 3 4 5 6 7 | @admin.register(Article) class ArticleAdmin(ModelAdmin): # ... filter_vertical = ("tags", ) # OR: filter_horizontal = ("tags", ) |
Here's the result:
Read-Only Fields
In case you don't want to let some fields be editable, there's a config readonly_fields
that helps you with that.
This is useful for when you don't want to let the user override some business logic. In our example, we want the published
field to be populated only when the publish
action is ran.
This is helpful because we will always know when the article was actually published.
1 2 3 4 5 6 7 | @admin.register(Article) class ArticleAdmin(ModelAdmin): # ... readonly_fields = ("published", ) # ... |
Django Admin Performance Tuning
Overriding get_queryset
When the Django Admin list view gets slow, it might be because you are displaying a field from a related model. For every row, Django makes an extra query to fetch the related object. Depending on how many rows and how costly the extra queries are the Django Admin can get quite slow. Here's how we can fix that by prefetching the related models:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | @admin.register(Article) class ArticleAdmin(ModelAdmin): form = ArticleAdminForm list_display = ( 'title', 'category', 'author', 'status', 'author_article_count', 'author_role' ) # ... def author_article_count(self, obj): return obj.author.article_set.all().count() author_article_count.short_description = "Author Experience" def author_role(self, obj): return obj.author.role def get_queryset(self, request): return Article.objects.all().select_related( "category", "author", ).prefetch_related( "author__article_set" ) # ... |
Let's break that down.
When displaying the list, Django will make an extra query for fetching an article's category, then its
author, then the author's articles. This can be reduced quite a bit. We can do a JOIN SQL query using the
select_related
method to fetch the related Author
and Category
models and then we can prefetch the author's articles using prefetch_related
.
In the end we reduced the queries to only 2. If you want to better understand the issue, you can research the
N + 1 Query Problem.
Using raw_id_fields
Here's a scenario. One of our models has a ForeignKey
to another model. This related model
has many instances and fetching all of them might prove very costly or maybe displaying a SelectWidget
with
a million choices is just bad user experience. Instead, we can just show the ID of the related model.
Let's take the Comment
model to exemplify this:
1 2 3 4 5 6 7 8 9 10 | @admin.register(Comment) class CommentAdmin(ModelAdmin): raw_id_fields = ("article", ) # <-- def summary(self, obj): return obj.text[:30] list_display = ('summary', ) list_filter = ('article', ) |
Here's how our admin interface looks before and after adding the raw_id_fields
:
Miscellaneous
Two different Admin views for the same model
For various reasons you might want to have different Django Admin interfaces for the same model.
The most common scenario I've encountered was the need to provide a simple interface with few bells and whistles,
and another one with advanced functionality for deeper debugging.
You can't do this with Django Admin in a straightforward way, but there is an acceptable workaround: creating a proxy
model.
1 2 3 4 5 6 7 8 9 | class ArticleProxy(Article): class Meta: proxy = True verbose_name_plural = "Articles (Barebones view)" @admin.register(ArticleProxy) class ArticleProxyAdmin(ModelAdmin): pass |
Writing custom filters for List View
The default Django Admin filters are great, but we might need something more depending on our business logic.
I am going to show you how to write a filter with some very specific filtering capabilities using the SimpleListFilter
class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | class SpecialArticleTypeListFilter(admin.SimpleListFilter): title = "Special Type" parameter_name = "special_type" def lookups(self, request, model_admin): return [ ( 'pending_review_written_by_editors', 'Written By Editors & pending reviewed' ), ( 'published_by_contributors', 'Published & Written By Contributors' ), ( 'published_by_inexperienced_contributors', 'Published & Written By Inexperienced Contributors' ), ] def queryset(self, request, queryset): if self.value() == "pending_review_written_by_editors": return queryset.filter( status=Article.Status.PENDING_REVIEW).filter( author__role=User.Role.EDITOR ) if self.value() == "published_by_contributors": return queryset.filter( status=Article.Status.PUBLISHED).filter( author__role=User.Role.CONTRIBUTOR ) if self.value() == "published_by_inexperienced_contributors": return queryset.annotate( experience=Count('author__article')).filter( status=Article.Status.PUBLISHED).filter( author__role=User.Role.CONTRIBUTOR).filter( experience__lte=3 ) return queryset |
Then we add it to the Article
admin like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 | @admin.register(Article) class ArticleAdmin(ModelAdmin): # ... list_filter = ( 'author', 'category', SpecialArticleTypeListFilter, # <-- 'published', 'status', 'author__role' ) |
And here's the new filter in action:
We've covered a lot of ground in this comprehensive guide to mastering Django Admin customization. From simple tweaks to more advanced recipes, we've explored various methods to tailor the admin interface to suit your project's needs. If you have your own tips, tricks, share them in the comments section below!