DEV Community

Vincent Tommi
Vincent Tommi

Posted on

How to Build a Powerful & Beginner-Friendly Django Admin

A Step-by-Step Tutorial Using a Real-World Fundraising Platform
Perfect for intermediate Django developers who want to go from “it works” to “this admin is actually amazing”.
We’ll use a real crowdfunding/startup fundraising app (with individuals, NGOs, and startups) to teach you every important Django admin feature — with copy-paste code and clear explanations.
By the end of this tutorial, you’ll know how to:

Show custom model properties in the list view
Add filters, search, and bulk edits
Use inlines to edit related models on the same page
Make the change-form beautiful with fieldsets and collapse sections
Add custom columns with links and formatted money
Write safe, performant querysets
Add helpful readonly fields

Let’s build it together!

# models.py (simplified)
class Fundraiser(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
    fundraising_category = models.ForeignKey(FundraisingCategory, ...)
    short_code = models.CharField(max_length=10, unique=True)
    is_approved = models.BooleanField(default=False)
    is_private = models.BooleanField(default=False)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
    created_at = models.DateTimeField(auto_now_add=True)

    @property
    def raised_amount(self):
        return self.paystack_transactions.filter(status='success').aggregate(
            total=Sum('amount')
        )['total'] or 0

class FundraiserImage(models.Model):
    fundraiser = models.ForeignKey(Fundraiser, on_delete=models.CASCADE, related_name='images')
    image = models.ImageField(...)
    is_primary = models.BooleanField(default=False)

class IndividualDetail(models.Model):  # OneToOne with Fundraiser
    fundraiser = models.OneToOneField(Fundraiser, related_name='individual_detail', ...)
    fundraiser_title = models.CharField(...)
    fundraiser_goal = models.DecimalField(...)

# + OrganisationDetail and StartupDetail (also OneToOne)


Enter fullscreen mode Exit fullscreen mode

Step 2: The Most Important Admin — FundraiserAdmin
This will be your main dashboard.

# admin.py
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from .models import Fundraiser, FundraiserImage, IndividualDetail, OrganisationDetail, StartupDetail


class FundraiserImageInline(admin.TabularInline):   # Step A: Inline images
    model = FundraiserImage
    extra = 1
    fields = ('name', 'image', 'is_primary')
    readonly_fields = ('file_size',)


@admin.register(Fundraiser)
class FundraiserAdmin(admin.ModelAdmin):
    # 1. What columns to show in the list
    list_display = (
        'short_code',
        'user',
        'category_colored',           # custom column (we'll write it)
        'raised_vs_goal',               # beautiful progress column
        'is_approved',
        'is_private',
        'status',
        'created_at',
    )

    # 2. Right sidebar filters
    list_filter = (
        'is_approved',
        'is_private',
        'status',
        'fundraising_category',
        'created_at',
    )

    # 3. Search box
    search_fields = ('short_code', 'user__email', 'user__username')

    # 4. Click the checkbox → edit these fields directly in place
    list_editable = ('is_approved', 'is_private', 'status')

    # 5. These fields are shown but not editable
    readonly_fields = ('short_code', 'created_at', 'updated_at')

    # 6. Inline images right on the same page
    inlines = [FundraiserImageInline]

    # 7. Default sorting
    ordering = ('-created_at',)

    # 8. Performance: avoid N+1 queries
    def get_queryset(self, request):
        return super().get_queryset(request).select_related(
            'user', 'fundraising_category'
        ).prefetch_related('paystack_transactions')

    # 9. Custom column: show category with color
    def category_colored(self, obj):
        color = {
            'Medical': 'crimson',
            'Education': 'royalblue',
            'Startup': 'green',
        }.get(obj.fundraising_category.name, 'gray')
        return format_html(
            '<span style="color: white; background:{}; padding: 2px 8px; border-radius: 4px;">{}</span>',
            color, obj.fundraising_category or "—"
        )
    category_colored.short_description = "Category"

    # 10. Custom column: $12,450 / $50,000 (54%)
    def raised_vs_goal(self, obj):
        goal = None
        if hasattr(obj, 'individual_detail'):
            goal = obj.individual_detail.fundraiser_goal
        elif hasattr(obj, 'organisation_details'):
            goal = obj.organisation_details.fundraiser_goal
        elif hasattr(obj, 'startup_detail'):
            goal = obj.startup_detail.fundraiser_goal

        if not goal:
            return "—"

        raised = obj.raised_amount
        percentage = (raised / goal * 100 if goal > 0 else 0

        return format_html(
            '<b>${:,.0f}</b> → ${:,.0f} <small>({:.0f}%)</small>',
            raised, goal, percentage
        )
    raised_vs_goal.short_description = "Raised / Goal"
Enter fullscreen mode Exit fullscreen mode

Result: Your admin list now looks professional and saves hours of clicking around.

Step 3: Make Individual/Organisation/Startup Pages Beautiful
Example: StartupDetailAdmin

@admin.register(StartupDetail)
class StartupDetailAdmin(admin.ModelAdmin):
    list_display = ('startup_name', 'fundraiser_link', 'industry', 'stage', 'fundraiser_goal')
    list_filter = ('industry', 'stage', 'team_size')
    search_fields = ('startup_name', 'fundraiser__short_code')

    # Group fields nicely on the edit page
    fieldsets = (
        ("Linked Fundraiser", {
            'fields': ('fundraiser',),
            'description': 'This startup belongs to the fundraiser below'
        }),
        ("Startup Info", {
            'fields': ('startup_name', 'business_description', 'location', 'website')
        }),
        ("Fundraising", {
            'fields': ('fundraiser_title', 'fundraiser_details', 'fundraiser_goal')
        }),
        ("Classification", {
            'fields': ('industry', 'stage', 'team_size')
        }),
        ("Social Media (optional)", {
            'fields': ('social_media',),
            'classes': ('collapse',)  # collapsed by default
        }),
    )

    readonly_fields = ('created_at', 'updated_at')

    # Nice clickable link back to the main fundraiser
    def fundraiser_link(self, obj):
        url = reverse('admin:yourapp_fundraiser_change', args=[obj.fundraiser.id])
        return format_html('<a href="{}">{} → View Fundraiser</a>', url, obj.fundraiser.short_code)
    fundraiser_link.short_description = "Fundraiser"

Enter fullscreen mode Exit fullscreen mode

Do the same for IndividualDetailAdmin and OrganisationDetailAdmin — just change the fields.

Step 4: Bonus — Useful Tricks Every Django Developer Should Know

# 1. Custom bulk actions
def approve_selected(modeladmin, request, queryset):
    updated = queryset.update(is_approved=True)
    modeladmin.message_user(request, f"{updated} fundraisers approved!")
approve_selected.short_description = "Approve selected fundraisers"

FundraiserAdmin.actions = ['approve_selected']

# 2. Show image preview in list
def admin_image_preview(self, obj):
    if obj.image:
        return format_html('<img src="{}" width="80" height="50" style="object-fit: cover;"/>', obj.image.url)
    return "(No image)"
admin_image_preview.short_description = "Preview"

Enter fullscreen mode Exit fullscreen mode

Final Result
You now have an admin that:

  • Non-technical staff love using
  • Shows real-time money raised
  • Lets you approve 50 campaigns in 10 seconds
  • Handles three different content types without confusion
  • Looks clean and professional

Copy the full final admin.py below:

# Full final admin.py (ready to copy-paste)
from django.contrib import admin
from django.db.models import Sum
from django.urls import reverse
from django.utils.html import format_html
from .models import (
    Fundraiser, FundraiserImage,
    IndividualDetail, OrganisationDetail, StartupDetail
)

class FundraiserImageInline(admin.TabularInline):
    model = FundraiserImage
    extra = 1
    fields = ('name', 'image', 'is_primary', 'file_size')
    readonly_fields = ('file_size',)

@admin.register(Fundraiser)
class FundraiserAdmin(admin.ModelAdmin):
    list_display = ('short_code', 'user', 'category_colored', 'raised_vs_goal',
                    'is_approved', 'is_private', 'status', 'created_at')
    list_filter = ('is_approved', 'is_private', 'status', 'fundraising_category')
    search_fields = ('short_code', 'user__email')
    list_editable = ('is_approved', 'is_private', 'status')
    readonly_fields = ('short_code', 'created_at', 'updated_at')
    inlines = [FundraiserImageInline]
    ordering = ('-created_at',)

    def get_queryset(self, request):
        return super().get_queryset(request).select_related(
            'user', 'fundraising_category'
        ).prefetch_related('paystack_transactions')

    def category_colored(self, obj):
        colors = {'Medical': 'crimson', 'Education': 'royalblue', 'Startup': 'green'}
        color = colors.get(obj.fundraising_category.name if obj.fundraising_category else '', 'gray')
        return format_html(
            '<span style="color:white; background:{}; padding:3px 8px; border-radius:4px;">{}</span>',
            color, obj.fundraising_category or "—"
        )
    category_colored.short_description = "Category"

    def raised_vs_goal(self, obj):
        # same as earlier — omitted for brevity
        pass
    raised_vs_goal.short_description = "Progress"

    def approve_selected(self, request, queryset):
        updated = queryset.update(is_approved=True)
        self.message_user(request, f"{updated} fundraisers approved.")
    approve_selected.short_description = "Approve selected"
    actions = ['approve_selected']
Enter fullscreen mode Exit fullscreen mode

Top comments (0)