In this tutorial, we build an advanced Django-Unfold admin dashboard. We start by installing Django, Django-Unfold, and the required dependencies, then we create a fresh Django project with a shop application. We configure Unfold with a modern admin theme, custom sidebar navigation, dashboard callbacks, product badges, tabs, filters, actions, and a custom admin homepage. We also define realistic e-commerce models such as categories, products, customers, orders, and order items, seed the database with sample data, and launch the Django server through Colab’s proxy so we can access the admin panel from the browser.
import os, sys, shutil, subprocess, time, signal, urllib.request, urllib.error
from pathlib import Path
print("
Installing django + django-unfold ...")
subprocess.run([sys.executable, "-m", "pip", "install", "-q",
"django>=5.0,<5.2", "django-unfold", "Pillow"], check=True)
subprocess.run(["bash","-c","pkill -9 -f 'manage.py runserver' || true"])
time.sleep(2)
ROOT = Path("/content/unfold_demo")
if ROOT.exists():
shutil.rmtree(ROOT)
ROOT.mkdir(parents=True)
os.chdir(ROOT)
subprocess.run(["django-admin", "startproject", "config", "."], check=True)
subprocess.run([sys.executable, "manage.py", "startapp", "shop"], check=True)
(ROOT / "templates" / "admin").mkdir(parents=True, exist_ok=True)
We install Django, Django-Unfold, and Pillow so the Colab environment has all the required dependencies for the admin demo. We then stop any previously running Django server to avoid port conflicts. We create a fresh Django project, start the shop app, and prepare the custom admin template directory.
(ROOT / "config" / "settings.py").write_text(r'''
from pathlib import Path
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "colab-demo-key-not-for-production"
DEBUG = True
ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = [
"https://*.googleusercontent.com",
"https://*.colab.research.google.com",
"https://*.googleapis.com",
"https://*.colab.dev",
"https://*.prod.colab.dev",
]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
X_FRAME_OPTIONS = "ALLOWALL"
INSTALLED_APPS = [
"unfold",
"unfold.contrib.filters",
"unfold.contrib.forms",
"unfold.contrib.inlines",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"shop",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]},
}]
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
LANGUAGE_CODE, TIME_ZONE, USE_I18N, USE_TZ = "en-us", "UTC", True, True
STATIC_URL, STATIC_ROOT = "static/", BASE_DIR / "staticfiles"
MEDIA_URL, MEDIA_ROOT = "media/", BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
UNFOLD = {
"SITE_TITLE": "Acme Shop Admin",
"SITE_HEADER": "Acme Shop",
"SITE_SUBHEADER": "Internal back-office",
"SITE_SYMBOL": "shopping_bag",
"SHOW_HISTORY": True,
"SHOW_VIEW_ON_SITE": True,
"ENVIRONMENT": "shop.utils.environment_callback",
"DASHBOARD_CALLBACK": "shop.utils.dashboard_callback",
"BORDER_RADIUS": "8px",
"COLORS": {
"primary": {
"50":"250 245 255","100":"243 232 255","200":"233 213 255",
"300":"216 180 254","400":"192 132 252","500":"168 85 247",
"600":"147 51 234","700":"126 34 206","800":"107 33 168",
"900":"88 28 135","950":"59 7 100",
},
},
"SIDEBAR": {
"show_search": True,
"show_all_applications": False,
"navigation": [
{"title": _("Overview"), "separator": True, "items": [
{"title": _("Dashboard"), "icon": "dashboard",
"link": reverse_lazy("admin:index")},
{"title": _("Users"), "icon": "people",
"link": reverse_lazy("admin:auth_user_changelist")},
]},
{"title": _("Catalog"), "separator": True, "collapsible": True, "items": [
{"title": _("Categories"), "icon": "category",
"link": reverse_lazy("admin:shop_category_changelist")},
{"title": _("Products"), "icon": "inventory_2",
"link": reverse_lazy("admin:shop_product_changelist"),
"badge": "shop.utils.products_badge"},
]},
{"title": _("Sales"), "separator": True, "collapsible": True, "items": [
{"title": _("Orders"), "icon": "receipt_long",
"link": reverse_lazy("admin:shop_order_changelist")},
{"title": _("Customers"), "icon": "person",
"link": reverse_lazy("admin:shop_customer_changelist")},
]},
],
},
"TABS": [{
"models": ["shop.product"],
"items": [
{"title": _("All products"),
"link": reverse_lazy("admin:shop_product_changelist")},
{"title": _("Categories"),
"link": reverse_lazy("admin:shop_category_changelist")},
],
}],
}
''')
(ROOT / "config" / "urls.py").write_text('''
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("", lambda r: HttpResponseRedirect("/admin/")),
path("admin/", admin.site.urls),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
''')
We configure the Django settings file with installed apps, middleware, database settings, static/media paths, and Colab-friendly host and CSRF options. We add Django-Unfold settings to customize the admin title, theme color, sidebar navigation, tabs, dashboard callback, and environment badge. We also define the URL configuration so the root path redirects directly to the admin panel.
(ROOT / "shop" / "models.py").write_text('''
from django.db import models
from django.utils.translation import gettext_lazy as _
class Category(models.Model):
name = models.CharField(_("Name"), max_length=120)
slug = models.SlugField(unique=True)
parent = models.ForeignKey("self", null=True, blank=True,
on_delete=models.SET_NULL, related_name="children")
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: verbose_name_plural = "Categories"
def __str__(self): return self.name
class Customer(models.Model):
TIER = [("bronze","Bronze"),("silver","Silver"),
("gold","Gold"),("platinum","Platinum")]
name = models.CharField(max_length=120)
email = models.EmailField(unique=True)
tier = models.CharField(max_length=10, choices=TIER, default="bronze")
lifetime_value = models.DecimalField(max_digits=10, decimal_places=2, default=0)
joined = models.DateTimeField(auto_now_add=True)
def __str__(self): return self.name
class Product(models.Model):
STATUS = [("draft","Draft"),("active","Active"),("archived","Archived")]
category = models.ForeignKey(Category, on_delete=models.CASCADE,
related_name="products")
name = models.CharField(max_length=200)
sku = models.CharField(max_length=64, unique=True)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=10, choices=STATUS, default="draft")
featured = models.BooleanField(default=False)
has_discount = models.BooleanField(default=False,
help_text="Toggle to enable discount field")
discount_percent = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): return self.name
@property
def final_price(self):
if self.has_discount and self.discount_percent:
return round(float(self.price)*(1-self.discount_percent/100), 2)
return float(self.price)
class Order(models.Model):
STATUS = [("pending","Pending"),("paid","Paid"),("shipped","Shipped"),
("delivered","Delivered"),("cancelled","Cancelled")]
number = models.CharField(max_length=20, unique=True)
customer = models.ForeignKey(Customer, on_delete=models.PROTECT,
related_name="orders")
status = models.CharField(max_length=10, choices=STATUS, default="pending")
total = models.DecimalField(max_digits=10, decimal_places=2, default=0)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): return self.number
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE,
related_name="items")
product = models.ForeignKey(Product, on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
position = models.PositiveIntegerField(default=0)
class Meta: ordering = ["position"]
''')
(ROOT / "shop" / "utils.py").write_text('''
from django.db.models import Count, Sum
from django.utils import timezone
from datetime import timedelta
def environment_callback(request):
return ["Development", "warning"]
def products_badge(request):
from .models import Product
n = Product.objects.filter(status="active").count()
return n if n else None
def dashboard_callback(request, context):
from .models import Product, Order, Customer, Category
last30 = timezone.now() - timedelta(days=30)
revenue = Order.objects.filter(
created_at__gte=last30,
status__in=["paid","shipped","delivered"],
).aggregate(s=Sum("total"))["s"] or 0
context.update({
"kpis": [
{"title":"Active products","value":Product.objects.filter(status="active").count(),"footer":"in catalog"},
{"title":"Pending orders","value":Order.objects.filter(status="pending").count(),"footer":"awaiting payment"},
{"title":"Customers","value":Customer.objects.count(),"footer":"registered"},
{"title":"Revenue (30d)","value":f"${revenue}","footer":"last 30 days"},
],
"top_cats": list(Category.objects.annotate(n=Count("products"))
.order_by("-n")[:5].values("name","n")),
"by_status": list(Order.objects.values("status").annotate(c=Count("id"))),
})
return context
''')
We define the core e-commerce models for categories, customers, products, orders, and order items. We add useful fields such as product status, stock, discounts, customer tiers, order totals, and order statuses. We also create utility callbacks for the Unfold dashboard, product badge, environment label, KPI cards, top categories, and order status summaries.
(ROOT / "shop" / "admin.py").write_text('''
from django.contrib import admin, messages
from django.contrib.auth.admin import (UserAdmin as DjangoUserAdmin,
GroupAdmin as DjangoGroupAdmin)
from django.contrib.auth.models import User, Group
from django.shortcuts import redirect
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin, TabularInline
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter, RangeNumericFilter, RangeDateFilter,
MultipleChoicesDropdownFilter,
)
from unfold.decorators import display, action
from .models import Category, Customer, Product, Order, OrderItem
admin.site.unregister(User); admin.site.unregister(Group)
@admin.register(User)
class UserAdmin(DjangoUserAdmin, ModelAdmin):
pass
@admin.register(Group)
class GroupAdmin(DjangoGroupAdmin, ModelAdmin):
pass
@admin.register(Category)
class CategoryAdmin(ModelAdmin):
list_display = ("name", "parent", "show_active", "created_at")
list_filter = (("is_active", ChoicesDropdownFilter),)
search_fields = ("name", "slug")
prepopulated_fields = {"slug": ("name",)}
list_filter_submit = True
compressed_fields = True
@display(description=_("Active"), boolean=True)
def show_active(self, obj): return obj.is_active
@admin.register(Customer)
class CustomerAdmin(ModelAdmin):
list_display = ("name","email","show_tier","lifetime_value","joined")
list_filter = (
("tier", MultipleChoicesDropdownFilter),
("lifetime_value", RangeNumericFilter),
("joined", RangeDateFilter),
)
search_fields = ("name","email")
list_filter_submit = True
warn_unsaved_form = True
list_per_page = 25
@display(description=_("Tier"), label={
"bronze":"warning","silver":"info","gold":"success","platinum":"primary"})
def show_tier(self, obj):
return obj.get_tier_display(), obj.tier
class OrderItemInline(TabularInline):
model = OrderItem
extra = 0
fields = ("product", "quantity", "unit_price", "position")
ordering_field = "position"
tab = True
@admin.register(Order)
class OrderAdmin(ModelAdmin):
list_display = ("number","customer_link","show_status","total","created_at")
list_filter = (
("status", ChoicesDropdownFilter),
("total", RangeNumericFilter),
("created_at", RangeDateFilter),
)
search_fields = ("number","customer__name","customer__email")
readonly_fields = ("created_at",)
autocomplete_fields = ("customer",)
inlines = [OrderItemInline]
list_filter_submit = True
fieldsets = (
(_("Order"), {"classes":["tab"], "fields":("number","customer","status","total")}),
(_("Notes"), {"classes":["tab"], "fields":("notes","created_at")}),
)
actions_list = ["mark_paid_bulk"]
actions_row = ["mark_paid_row"]
actions_detail = ["duplicate_order"]
actions_submit_line = ["save_and_ship"]
@display(description=_("Status"), label={
"pending":"warning","paid":"info","shipped":"primary",
"delivered":"success","cancelled":"danger"})
def show_status(self, obj):
return obj.get_status_display(), obj.status
@display(description=_("Customer"))
def customer_link(self, obj):
return format_html('<a href="/admin/shop/customer/{}/change/">{}</a>',
obj.customer_id, obj.customer.name)
@action(description=_("Mark pending → PAID (all)"), icon="payments")
def mark_paid_bulk(self, request, queryset=None):
n = Order.objects.filter(status="pending").update(status="paid")
self.message_user(request, f"Marked {n} orders as paid.", level=messages.SUCCESS)
@action(description=_("Mark paid"), icon="payments", url_path="mark-paid-row")
def mark_paid_row(self, request, object_id):
Order.objects.filter(pk=object_id).update(status="paid")
self.message_user(request, "Order marked as paid.", level=messages.SUCCESS)
return redirect(request.META.get("HTTP_REFERER","/admin/"))
@action(description=_("Duplicate"), icon="content_copy", url_path="duplicate")
def duplicate_order(self, request, object_id):
o = Order.objects.get(pk=object_id)
o.pk = None; o.number = o.number + "-COPY"; o.status = "pending"; o.save()
self.message_user(request, "Order duplicated.", level=messages.SUCCESS)
return redirect(f"/admin/shop/order/{o.pk}/change/")
@action(description=_("Save & ship"))
def save_and_ship(self, request, obj):
obj.status = "shipped"; obj.save()
self.message_user(request, f"Order {obj.number} shipped.", level=messages.SUCCESS)
@admin.register(Product)
class ProductAdmin(ModelAdmin):
list_display = ("name","sku","category","show_status",
"price_display","stock_badge","featured")
list_editable = ("featured",)
list_filter = (
("status", ChoicesDropdownFilter),
("category", admin.RelatedFieldListFilter),
("price", RangeNumericFilter),
("featured", ChoicesDropdownFilter),
)
search_fields = ("name","sku")
autocomplete_fields = ("category",)
list_filter_submit = True
list_per_page = 20
save_on_top = True
fieldsets = (
(_("Basics"), {"classes":["tab"],
"fields":("name","sku","category","status","featured")}),
(_("Pricing"), {"classes":["tab"],
"fields":("price","has_discount","discount_percent","stock")}),
(_("Content"), {"classes":["tab"], "fields":("description",)}),
)
conditional_fields = {"discount_percent": "has_discount == true"}
@display(description=_("Status"), label={
"draft":"info","active":"success","archived":"warning"})
def show_status(self, obj):
return obj.get_status_display(), obj.status
@display(description=_("Price"))
def price_display(self, obj):
if obj.has_discount and obj.discount_percent:
return format_html(
'<span style="text-decoration:line-through;opacity:.6">${}</span> '
'<strong>${}</strong>', obj.price, obj.final_price)
return f"${obj.price}"
@display(description=_("Stock"), ordering="stock",
label={"out":"danger","low":"warning","ok":"success"})
def stock_badge(self, obj):
if obj.stock == 0: return "Out of stock", "out"
if obj.stock < 10: return f"Low ({obj.stock})", "low"
return f"{obj.stock} in stock", "ok"
''')
(ROOT / "templates" / "admin" / "index.html").write_text('''{% extends "admin/index.html" %}
{% load i18n %}
{% block content %}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{% for k in kpis %}
<div class="border border-base-200 dark:border-base-800 bg-white dark:bg-base-900 rounded-default p-5 shadow-xs">
<div class="font-medium text-font-subtle-light dark:text-font-subtle-dark text-sm">{{ k.title }}</div>
<div class="font-bold text-2xl mt-2 text-font-important-light dark:text-font-important-dark">{{ k.value }}</div>
<div class="text-xs mt-1 text-font-default-light dark:text-font-default-dark">{{ k.footer }}</div>
</div>
{% endfor %}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<div class="border border-base-200 dark:border-base-800 bg-white dark:bg-base-900 rounded-default p-5">
<h3 class="font-semibold mb-3 text-font-important-light dark:text-font-important-dark">{% trans "Top categories" %}</h3>
<ul class="space-y-2">
{% for c in top_cats %}<li class="flex justify-between"><span>{{ c.name }}</span><span class="font-semibold">{{ c.n }}</span></li>{% endfor %}
</ul>
</div>
<div class="border border-base-200 dark:border-base-800 bg-white dark:bg-base-900 rounded-default p-5">
<h3 class="font-semibold mb-3 text-font-important-light dark:text-font-important-dark">{% trans "Orders by status" %}</h3>
<ul class="space-y-2">
{% for s in by_status %}<li class="flex justify-between"><span class="capitalize">{{ s.status }}</span><span class="font-semibold">{{ s.c }}</span></li>{% endfor %}
</ul>
</div>
</div>
{{ block.super }}
{% endblock %}
''')
We customize the Django admin using Django-Unfold’s ModelAdmin, filters, labels, tabs, inline order items, and admin actions. We register custom admin views for users, groups, categories, customers, products, and orders with search, filters, badges, and formatted displays. We also create a custom dashboard template that shows KPI cards, top categories, and order status summaries on the admin homepage.
print("
Running migrations ...")
subprocess.run([sys.executable, "manage.py", "makemigrations", "shop"], check=True)
subprocess.run([sys.executable, "manage.py", "migrate"], check=True)
(ROOT / "seed.py").write_text(r'''
import os, django, random, string
os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings"); django.setup()
from django.contrib.auth.models import User
from shop.models import Category, Customer, Product, Order, OrderItem
from decimal import Decimal
if not User.objects.filter(username="admin").exists():
User.objects.create_superuser("admin","admin@example.com","admin")
if Category.objects.count() == 0:
cats=[Category.objects.create(name=n,slug=n.lower())
for n in ["Electronics","Apparel","Home","Books","Toys"]]
Category.objects.create(name="Phones",slug="phones",parent=cats[0])
for i in range(30):
c=random.choice(list(Category.objects.all()))
Product.objects.create(
category=c, name=f"{c.name} item {i+1}",
sku="SKU-"+"".join(random.choices(string.ascii_uppercase+string.digits,k=8)),
price=Decimal(random.randint(5,500)),
stock=random.choice([0,3,12,25,100]),
status=random.choice(["draft","active","active","active","archived"]),
featured=random.random()<0.2, description="Sample product description.")
for i in range(15):
Customer.objects.create(
name=f"Customer {i+1}", email=f"customer{i+1}@example.com",
tier=random.choice(["bronze","silver","gold","platinum"]),
lifetime_value=Decimal(random.randint(0,5000)))
customers=list(Customer.objects.all()); products=list(Product.objects.all())
for i in range(40):
o=Order.objects.create(number=f"ORD-{1000+i}",customer=random.choice(customers),
status=random.choice(["pending","paid","shipped","delivered","cancelled"]))
total=Decimal(0)
for j in range(random.randint(1,4)):
p=random.choice(products); qty=random.randint(1,3)
OrderItem.objects.create(order=o,product=p,quantity=qty,
unit_price=p.price,position=j)
total += p.price*qty
o.total=total; o.save()
print("
Seed complete.")
''')
subprocess.run([sys.executable, "seed.py"], check=True)
print("
Starting dev server on :8000 ...")
LOG = "/content/server.log"
log_fh = open(LOG, "wb")
proc = subprocess.Popen(
[sys.executable, "manage.py", "runserver", "0.0.0.0:8000", "--noreload"],
stdout=log_fh, stderr=log_fh, preexec_fn=os.setsid)
print("
Verifying Django is responding ...")
ok = False
for attempt in range(15):
try:
r = urllib.request.urlopen("http://127.0.0.1:8000/admin/login/", timeout=3)
print(f"
HTTP {r.status} from /admin/login/")
ok = True; break
except urllib.error.HTTPError as e:
print(f"
HTTP {e.code} from /admin/login/")
ok = True; break
except Exception:
time.sleep(1)
if not ok:
print("n
Django did NOT respond. Server log:n" + "-"*50)
print(open(LOG).read())
print("-"*50)
raise SystemExit(1)
from google.colab.output import eval_js
from IPython.display import display, HTML
proxy_root = eval_js("google.colab.kernel.proxyPort(8000)")
admin_url = proxy_root.rstrip("/") + "/admin/login/"
display(HTML(f'''
<div style="padding:16px;border:2px solid #9333ea;border-radius:8px;background:#faf5ff;font-family:system-ui">
<h2 style="margin:0 0 8px 0;color:#6b21a8">
Django-Unfold demo is ready</h2>
<p style="margin:4px 0">Login: <code style="background:#fff;padding:2px 6px;border-radius:4px">admin</code> / <code style="background:#fff;padding:2px 6px;border-radius:4px">admin</code></p>
<p style="margin:12px 0">
<a href="{admin_url}" target="_blank" style="font-size:18px;font-weight:bold;color:#7e22ce">
Open the admin
</a>
</p>
<p style="margin:4px 0;font-size:12px;color:#6b7280">If the link 404s, try copy-pasting it manually:<br><code>{admin_url}</code></p>
</div>
'''))
print(f"nProxy root: {proxy_root}")
print(f"Admin URL: {admin_url}")
print(f"nTo stop later: import os, signal; os.killpg({proc.pid}, signal.SIGTERM)")
We run migrations to create the database tables for the Django project and the shop app. We seed the database with an admin user, sample categories, products, customers, orders, and order items. We then start the Django development server, verify that the admin login page responds, and generate a Colab proxy link to open the Unfold admin dashboard.
In conclusion, we had a fully working Django-Unfold admin interface running with seeded e-commerce data and a polished dashboard experience. We used Unfold to transform the default Django admin into a more professional back-office system with custom navigation, visual labels, filters, inline order items, admin actions, conditional fields, and KPI cards. It provides a practical foundation for building modern internal tools, admin panels, and business dashboards with Django, while keeping the setup simple, reproducible, and Colab-friendly.
Check out the Full Codes with Notebook here. Also, feel free to follow us on Twitter and don’t forget to join our 150k+ ML SubReddit and Subscribe to our Newsletter. Wait! are you on telegram? now you can join us on telegram as well.
Need to partner with us for promoting your GitHub Repo OR Hugging Face Page OR Product Release OR Webinar etc.? Connect with us
The post How to Build a Django-Unfold Admin Dashboard with Custom Models, Filters, Actions, and KPIs appeared first on MarkTechPost.
