from unfold.components import BaseComponent, register_component
from django.template.loader import render_to_string
Components are registered globally and rendered on the admin index page:
@register_component
class ActiveUsersKPI(BaseComponent):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["children"] = render_to_string("myapp/kpi_card.html", {
"total": User.objects.filter(is_active=True).count(),
"label": "Active Users",
"progress": "positive", # or "negative"
"percentage": "+5.2%",
})
return context
<!-- templates/myapp/kpi_card.html -->
<div class="flex flex-col gap-1">
<div class="text-2xl font-bold text-base-900 dark:text-base-100">
{{ total }}
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-base-500 dark:text-base-400">{{ label }}</span>
{% if percentage %}
<span class="text-xs {% if progress == 'positive' %}text-green-600{% else %}text-red-600{% endif %}">
{{ percentage }}
</span>
{% endif %}
</div>
</div>
Unfold supports Chart.js via custom components:
@register_component
class SalesChartComponent(BaseComponent):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["data"] = json.dumps({
"labels": ["Mon", "Tue", "Wed", "Thu", "Fri"],
"datasets": [{
"data": [[1, 5], [1, 8], [1, 12], [1, 7], [1, 15]],
"backgroundColor": "var(--color-primary-600)",
}],
})
return context
Components are rendered in the admin index template. Customize the index template to control layout:
<!-- templates/admin/index.html -->
{% extends "unfold/layouts/base_simple.html" %}
{% load unfold %}
{% block content %}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-8">
{% component "ActiveUsersKPI" %}{% endcomponent %}
{% component "RevenueKPI" %}{% endcomponent %}
{% component "OrdersKPI" %}{% endcomponent %}
{% component "ConversionKPI" %}{% endcomponent %}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{% component "SalesChartComponent" %}{% endcomponent %}
{% component "RecentOrdersComponent" %}{% endcomponent %}
</div>
{% endblock %}
Configure in settings:
UNFOLD = {
"DASHBOARD_CALLBACK": "myapp.views.dashboard_callback",
}
The callback prepares template context for the admin index:
# myapp/views.py
def dashboard_callback(request, context):
"""Add extra context to admin dashboard."""
context.update({
"custom_variable": "value",
"stats": get_dashboard_stats(),
})
return context
Sections are panels displayed below the changelist table.
from unfold.sections import TableSection, TemplateSection
Renders a related model's data as a table:
class RecentOrdersSection(TableSection):
related_name = "order_set" # related manager name
fields = ["id", "total", "status", "custom_field"]
height = 380 # fixed height in px
@admin.display(description=_("Formatted Total"))
def custom_field(self, instance):
return f"${instance.total:.2f}"
Renders a custom template:
class AnalyticsSection(TemplateSection):
template_name = "myapp/analytics_chart.html"
class MyAdmin(ModelAdmin):
list_sections = [RecentOrdersSection, AnalyticsSection]
list_sections_classes = "lg:grid-cols-2" # CSS grid layout
Datasets embed full mini-admin listings within change forms. Think of them as "related admin views" rendered inside another model's edit page.
from unfold.datasets import BaseDataset
from unfold.admin import ModelAdmin
# Step 1: Define a mini-admin for the dataset
class RelatedItemDatasetAdmin(ModelAdmin):
list_display = ["name", "status", "created_at"]
search_fields = ["name"]
actions = ["bulk_approve"]
def bulk_approve(self, request, queryset):
queryset.update(status="approved")
messages.success(request, "Approved.")
return redirect(request.headers.get("referer"))
def get_queryset(self, request):
obj = self.extra_context.get("object") # parent object
if not obj:
return super().get_queryset(request).none()
return super().get_queryset(request).filter(parent=obj)
# Step 2: Define the dataset
class RelatedItemDataset(BaseDataset):
model = RelatedItem
model_admin = RelatedItemDatasetAdmin
tab = True # render as tab on change form
# Step 3: Register on the parent admin
class ParentAdmin(ModelAdmin):
change_form_datasets = [RelatedItemDataset]
| Field | Type | Description |
|---|---|---|
model |
Model class | The related model |
model_admin |
ModelAdmin class | Mini-admin configuration |
tab |
bool | Show as tab on change form |
In the dataset's model_admin, access the parent object via self.extra_context:
def get_queryset(self, request):
obj = self.extra_context.get("object")
if not obj:
return super().get_queryset(request).none()
return super().get_queryset(request).filter(owner=obj)
class MyAdmin(ModelAdmin):
list_before_template = "myapp/list_before.html" # above table
list_after_template = "myapp/list_after.html" # below table
class MyAdmin(ModelAdmin):
change_form_before_template = "myapp/form_before.html" # above form
change_form_after_template = "myapp/form_after.html" # below form
Injected templates receive the standard Django admin template context plus the Unfold context. Use {{ cl }} for changelist context, {{ original }} for the object in change form templates.
<!-- templates/myapp/list_before.html -->
<div class="rounded-lg border border-base-200 dark:border-base-700 p-4 mb-4">
<h3 class="text-lg font-semibold text-base-900 dark:text-base-100">
Quick Stats
</h3>
<p class="text-base-500 dark:text-base-400">
Showing {{ cl.result_count }} results
</p>
</div>
<!-- templates/admin/index.html -->
{% extends "unfold/layouts/base_simple.html" %}
{% load i18n unfold %}
{% block title %}
{{ title }} | {{ site_title }}
{% endblock %}
{% block content %}
{% include "myapp/dashboards.html" %}
{% endblock %}
<!-- templates/myapp/dashboards.html -->
{% load unfold %}
<!-- KPI Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{% component "KPI1" %}{% endcomponent %}
{% component "KPI2" %}{% endcomponent %}
{% component "KPI3" %}{% endcomponent %}
{% component "KPI4" %}{% endcomponent %}
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{% component "Chart1" %}{% endcomponent %}
{% component "Chart2" %}{% endcomponent %}
</div>
<!-- Tables Row -->
<div class="grid grid-cols-1 gap-4">
{% component "RecentActivity" %}{% endcomponent %}
</div>
Unfold uses Tailwind CSS. Common utility patterns for custom templates:
| Pattern | Classes |
|---|---|
| Card | rounded-lg border border-base-200 dark:border-base-700 bg-white dark:bg-base-900 p-4 |
| Heading | text-lg font-semibold text-base-900 dark:text-base-100 |
| Muted text | text-sm text-base-500 dark:text-base-400 |
| Grid layout | grid grid-cols-1 lg:grid-cols-2 gap-4 |
| Flex row | flex items-center gap-2 |
from unfold.paginator import InfinitePaginator
class MyAdmin(ModelAdmin):
paginator = InfinitePaginator
show_full_result_count = False
list_per_page = 20
InfinitePaginator provides infinite scroll instead of page numbers.
cohort_data = {
"headers": [
{"title": "Week 1", "subtitle": "Jan 1-7"},
{"title": "Week 2", "subtitle": "Jan 8-14"},
],
"rows": [
{
"header": {"title": "Cohort A", "subtitle": "100 users"},
"cols": [
{"value": "85%", "subtitle": "85 users"},
{"value": "72%", "subtitle": "72 users"},
],
},
],
}
tracker_data = [
{"color": "bg-primary-400 dark:bg-primary-700", "tooltip": "Jan 1: 5 events"},
{"color": "bg-primary-200 dark:bg-primary-900", "tooltip": "Jan 2: 2 events"},
{"color": "bg-danger-400 dark:bg-danger-700", "tooltip": "Jan 3: 0 events"},
]
# Single bar
progress_single = {
"title": "Completion",
"description": "57.5%",
"value": 57.5,
}
# Multi-segment bar
progress_multi = {
"title": "Distribution",
"description": "Total 100%",
"items": [
{"title": "Active", "value": 60.0, "progress-class": "bg-primary-500"},
{"title": "Pending", "value": 25.0, "progress-class": "bg-warning-500"},
{"title": "Inactive", "value": 15.0, "progress-class": "bg-danger-500"},
],
}
table_data = {
"headers": ["Name", "Status", "Amount"],
"rows": [
["John", "Active", "$500"],
["Jane", "Inactive", "$300"],
],
"collapsible": True, # optional
}