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)
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"
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"
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"
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']
Top comments (0)