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("https://www.marktechpost.com/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("https://www.marktechpost.com/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('{}', 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","https://www.marktechpost.com/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"https://www.marktechpost.com/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( '${} ' '${}', 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 %} {% for k in kpis %} {{ k.title }} {{ k.value }} {{ k.footer }} {% endfor %} {% trans "Top categories" %}
{% for c in top_cats %}- {{ c.name }}{{ c.n }}
{% endfor %}
{% trans "Orders by status" %}
{% for s in by_status %}- {{ s.status }}{{ s.c }}
{% endfor %}
{{ 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","[email protected]","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 = "https://www.marktechpost.com/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("https://www.marktechpost.com/") + "/admin/login/" display(HTML(f''' ✅ Django-Unfold demo is ready
Login: admin / admin
If the link 404s, try copy-pasting it manually:
{admin_url}
''')) 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
Sana Hassan
Sana Hassan, a consulting intern at Marktechpost and dual-degree student at IIT Madras, is passionate about applying technology and AI to address real-world challenges. With a keen interest in solving practical problems, he brings a fresh perspective to the intersection of AI and real-life solutions.

