Introduction
The ModelAdmin class is the cornerstone of Django Unfold. It extends Django’s standard ModelAdmin with enhanced UI capabilities, additional configuration options, and powerful customization features while maintaining full backward compatibility.
from unfold.admin import ModelAdmin
class ArticleAdmin ( ModelAdmin ):
list_display = [ 'title' , 'author' , 'published_at' ]
search_fields = [ 'title' , 'content' ]
Every Django admin attribute you’re familiar with works in Unfold’s ModelAdmin. You’re extending functionality, not replacing it.
Class Hierarchy
Unfold’s ModelAdmin inherits from multiple mixins and Django’s base admin:
class ModelAdmin (
BaseModelAdminMixin , # Widget and form handling
ActionModelAdminMixin , # Enhanced actions
DatasetModelAdminMixin , # Chart integration
BaseModelAdmin # Django's admin.ModelAdmin
):
pass
This composition pattern allows Unfold to:
Override form widgets automatically
Add custom action types
Integrate visualization components
Preserve all Django admin functionality
Configuration Attributes
Control how forms are rendered in the change view:
class MyModelAdmin ( ModelAdmin ):
# Use compressed layout for form fields
compressed_fields = True
# Warn users about unsaved changes
warn_unsaved_form = True
# Show/hide add link in related field widgets
show_add_link = True
When enabled, form fields use a more compact layout with reduced spacing. Use case: Forms with many fields that benefit from a denser layout.
List Display Options
Customize the change list view appearance and behavior:
class MyModelAdmin ( ModelAdmin ):
# Add horizontal scrollbar at top of list
list_horizontal_scrollbar_top = True
# Require filter submission (no auto-apply)
list_filter_submit = True
# Use sheet-style filters instead of sidebar
list_filter_sheet = True
# Make list take full width
list_fullwidth = True
# Disable "select all" functionality
list_disable_select_all = True
Useful for wide tables where users need to scroll horizontally frequently. Having a scrollbar at both top and bottom improves UX.
class ProductAdmin ( ModelAdmin ):
list_display = [ 'sku' , 'name' , 'category' , 'price' , 'stock' , 'supplier' , 'created' , 'modified' ]
list_horizontal_scrollbar_top = True
list_filter_submit
When enabled, filters won’t auto-apply. Users must click a submit button. This is better for performance with expensive filters.
class OrderAdmin ( ModelAdmin ):
list_filter = [ 'status' , 'created_at' , 'total' ]
list_filter_submit = True # Users must click "Apply" to filter
list_filter_sheet
By default, Unfold uses a sheet (modal) for filters on mobile. Set to False to use sidebar filtering:
class UserAdmin ( ModelAdmin ):
list_filter_sheet = False # Always use sidebar
list_fullwidth
Make the change list take the full width of the viewport:
class LogEntryAdmin ( ModelAdmin ):
list_display = [ 'timestamp' , 'user' , 'action' , 'ip_address' , 'details' ]
list_fullwidth = True # Use all available space
Template Injection
Inject custom templates at strategic points in the admin interface:
class ArticleAdmin ( ModelAdmin ):
# Change list templates
list_before_template = "myapp/stats_header.html"
list_after_template = "myapp/bulk_actions.html"
# Change form templates (inner)
change_form_before_template = "myapp/form_instructions.html"
change_form_after_template = "myapp/form_footer.html"
# Change form templates (outer wrapper)
change_form_outer_before_template = "myapp/outer_header.html"
change_form_outer_after_template = "myapp/outer_footer.html"
List Templates
Form Templates
Outer Templates
list_before_template : Injected before the change list tablelist_after_template : Injected after the change list table<!-- myapp/stats_header.html -->
< div class = "bg-blue-50 p-4 rounded mb-4" >
< h3 > Total Records: {{ cl.result_count }} </ h3 >
</ div >
change_form_before_template : Before form fieldschange_form_after_template : After form fields<!-- myapp/form_instructions.html -->
< div class = "alert alert-info" >
< p > Please review all required fields carefully. </ p >
</ div >
change_form_outer_before_template : Before entire form containerchange_form_outer_after_template : After entire form container<!-- myapp/outer_header.html -->
< div class = "banner" >
< strong > Editing in Production Environment </ strong >
</ div >
Control field organization differently for add vs change forms:
class UserAdmin ( ModelAdmin ):
# Standard fieldsets for change form
fieldsets = (
( 'Personal Info' , {
'fields' : ( 'username' , 'email' , 'first_name' , 'last_name' )
}),
( 'Permissions' , {
'fields' : ( 'is_active' , 'is_staff' , 'groups' )
}),
)
# Different fieldsets for add form
add_fieldsets = (
( 'Create User' , {
'fields' : ( 'username' , 'email' , 'password1' , 'password2' )
}),
)
Use add_fieldsets when the add form needs different fields than the change form (common with User models).
Ordering Fields
Add drag-and-drop ordering to your model:
class CategoryAdmin ( ModelAdmin ):
list_display = [ 'name' , 'parent' , 'order' ]
# Enable ordering
ordering_field = 'order'
# Hide the ordering field from display
hide_ordering_field = True
How it works:
ordering_field specifies which model field stores the order
Unfold automatically adds drag handles to list rows
hide_ordering_field keeps the order field visible but styled for drag operations
from django.db import models
class Category ( models . Model ):
name = models.CharField( max_length = 100 )
order = models.PositiveIntegerField( default = 0 )
class Meta :
ordering = [ 'order' ]
Custom URLs
Add custom views accessible from the admin:
from django.shortcuts import render
from django.http import HttpResponse
def export_view ( request , model_admin ):
# Custom export logic
return HttpResponse( "Export complete" )
class ArticleAdmin ( ModelAdmin ):
custom_urls = (
( "export/" , "article_export" , export_view),
)
The custom URL format: (path, url_name, view_function)
Accessing custom URLs:
# In templates
{ % url 'admin:blog_article_article_export' % }
# In code
reverse( 'admin:blog_article_article_export' )
Custom views are automatically wrapped with admin_site.admin_view() for permission checking.
Readonly Preprocessing
Transform readonly field values before display:
class OrderAdmin ( ModelAdmin ):
readonly_fields = [ 'total' , 'status_display' , 'created_by' ]
readonly_preprocess_fields = {
'total' : lambda x : f "$ { x :,.2f} " ,
'status_display' : lambda x : x.upper(),
}
Use cases:
Format currency values
Transform dates
Add prefixes/suffixes
Apply text transformations
class TransactionAdmin ( ModelAdmin ):
readonly_fields = [ 'amount' , 'transaction_date' , 'reference' ]
readonly_preprocess_fields = {
'amount' : lambda value : f "$ { value :,.2f} USD" ,
'reference' : lambda value : f "REF- { value } " ,
}
Unfold uses a custom action form with improved styling:
from unfold.forms import ActionForm
class MyActionForm ( ActionForm ):
extra_field = forms.CharField( required = False )
class MyModelAdmin ( ModelAdmin ):
action_form = MyActionForm
Dataset Integration
Add charts and visualizations to the change form:
from unfold.datasets import BaseDataset
import json
class SalesDataset ( BaseDataset ):
title = "Monthly Sales"
def get_data ( self ):
# Fetch data from your model
data = self .model.objects.values( 'month' ).annotate(
total = Sum( 'amount' )
)
return {
'labels' : [item[ 'month' ] for item in data],
'datasets' : [{
'label' : 'Sales' ,
'data' : [item[ 'total' ] for item in data]
}]
}
class SalesAdmin ( ModelAdmin ):
change_form_datasets = (SalesDataset,)
Datasets appear above the form in the change view, providing context and insights about the object being edited.
Unfold automatically includes required CSS and JavaScript. Add custom media:
class MyModelAdmin ( ModelAdmin ):
class Media :
css = {
'all' : ( 'myapp/custom.css' ,)
}
js = ( 'myapp/custom.js' ,)
Method Overrides
Common methods you might override:
get_list_display
class ArticleAdmin ( ModelAdmin ):
def get_list_display ( self , request ):
list_display = [ 'title' , 'author' ]
if request.user.is_superuser:
list_display.append( 'internal_notes' )
return list_display
get_fieldsets
class UserAdmin ( ModelAdmin ):
def get_fieldsets ( self , request , obj = None ):
fieldsets = super ().get_fieldsets(request, obj)
if not request.user.is_superuser:
# Remove permission fieldset for non-superusers
fieldsets = [fs for fs in fieldsets if fs[ 0 ] != 'Permissions' ]
return fieldsets
changelist_view
class ProductAdmin ( ModelAdmin ):
def changelist_view ( self , request , extra_context = None ):
extra_context = extra_context or {}
extra_context[ 'summary_stats' ] = self .get_summary_stats()
return super ().changelist_view(request, extra_context)
def get_summary_stats ( self ):
return {
'total_products' : self .model.objects.count(),
'total_value' : self .model.objects.aggregate(Sum( 'price' ))[ 'price__sum' ]
}
Response Handling
Unfold extends response methods to support ?next= parameter:
class ArticleAdmin ( ModelAdmin ):
def response_add ( self , request , obj , post_url_continue = None ):
# If ?next= parameter exists, redirect there
response = super ().response_add(request, obj, post_url_continue)
return response
def response_change ( self , request , obj ):
# Same for change view
response = super ().response_change(request, obj)
return response
Usage:
# Link to admin with return URL
url = reverse( 'admin:blog_article_change' , args = [article.pk])
url += '?next=/dashboard/'
Unfold automatically replaces widgets but you can customize per field:
from unfold.widgets import UnfoldAdminTextInputWidget
class ArticleAdmin ( ModelAdmin ):
formfield_overrides = {
models.TextField: {
'widget' : UnfoldAdminTextareaWidget( attrs = {
'rows' : 10 ,
'class' : 'custom-class'
})
}
}
Text Inputs
UnfoldAdminTextInputWidget
UnfoldAdminTextareaWidget
UnfoldAdminExpandableTextareaWidget
UnfoldAdminEmailInputWidget
UnfoldAdminURLInputWidget
Selections
UnfoldAdminSelectWidget
UnfoldAdminSelectMultipleWidget
UnfoldAdminRadioSelectWidget
UnfoldAdminAutocompleteWidget
Dates & Times
UnfoldAdminDateWidget
UnfoldAdminTimeWidget
UnfoldAdminSplitDateTimeWidget
Special
UnfoldBooleanWidget
UnfoldBooleanSwitchWidget
UnfoldAdminFileFieldWidget
UnfoldAdminImageFieldWidget
Inline Admin
Unfold provides enhanced inline classes:
from unfold.admin import TabularInline, StackedInline
class CommentInline ( TabularInline ):
model = Comment
extra = 1
# Unfold-specific options
per_page = 10 # Paginate inline rows
collapsible = True # Make inline collapsible
ordering_field = 'order' # Enable drag-and-drop ordering
class ArticleAdmin ( ModelAdmin ):
inlines = [CommentInline]
Inline pagination is essential for models with many related objects. It prevents performance issues and improves UX.
class OrderItemInline ( TabularInline ):
model = OrderItem
per_page = 20
extra = 0
Complete Example
Here’s a comprehensive ModelAdmin example:
from django.contrib import admin
from unfold.admin import ModelAdmin
from unfold.decorators import action, display
from .models import Article
class ArticleAdmin ( ModelAdmin ):
# Standard Django admin options
list_display = [ 'title' , 'author' , 'status_badge' , 'published_at' ]
list_filter = [ 'status' , 'published_at' , 'author' ]
search_fields = [ 'title' , 'content' ]
date_hierarchy = 'published_at'
# Unfold display options
list_horizontal_scrollbar_top = True
list_filter_submit = True
compressed_fields = True
warn_unsaved_form = True
# Fieldsets
fieldsets = (
( 'Content' , {
'fields' : ( 'title' , 'slug' , 'content' , 'excerpt' )
}),
( 'Metadata' , {
'fields' : ( 'author' , 'status' , 'published_at' ),
'classes' : ( 'collapse' ,)
}),
)
# Template injection
list_before_template = 'blog/admin/article_stats.html'
# Readonly preprocessing
readonly_fields = [ 'view_count' , 'created_at' ]
readonly_preprocess_fields = {
'view_count' : lambda x : f " { x :,} views"
}
@display ( description = "Status" , label = True )
def status_badge ( self , obj ):
colors = {
'draft' : 'warning' ,
'published' : 'success' ,
'archived' : 'secondary'
}
return colors.get(obj.status, 'secondary' )
admin.site.register(Article, ArticleAdmin)
This example demonstrates the seamless integration of Django’s standard admin options with Unfold’s enhancements.