Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 031 / 100

The Django admin, used well: what developers should know

A free back office if you configure it.

Expona Admin8 min read

Introduction: Your free back office is waiting

Most teams building a Django backend spend weeks building custom admin dashboards that Django gives you for free. The Django admin, used well: what developers should know is the difference between a scattered toolbox and a professional operations interface. When configured properly, it handles 80% of your internal operations work without touching the application codebase.

We've seen this pattern across e-commerce, CRM, and SaaS projects: teams either ignore the admin entirely, treating it as a sandbox, or they spend months perfecting a bespoke internal tool that reinvents what's already there. The middle path—using Django's admin with discipline—saves months of work and keeps your team focused on user-facing features.

List display, search fields, and filters: your operational backbone

The admin list view is where your team spends 70% of their time in the back office. Getting it right means they can find, understand, and act on data without running queries or asking engineers for help.

list_display controls which columns appear on the list view. By default, you get the object's string representation. That's rarely enough.

python
from django.contrib import admin
from django.utils.html import format_html
from .models import Order

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('order_id', 'customer_email', 'total_amount', 
                    'status_badge', 'created_at')
    
    def status_badge(self, obj):
        colors = {
            'pending': '#FFA500',
            'confirmed': '#008000',
            'shipped': '#0000FF',
            'cancelled': '#FF0000'
        }
        color = colors.get(obj.status, '#808080')
        return format_html(
            '<span>{}</span>',
            color, obj.get_status_display()
        )
    status_badge.short_description = 'Status'

This single change transforms the list view from useless to actionable. Your ops team can see order status at a glance without clicking through.

search_fields lets your team find records by natural identifiers. Don't search on primary keys; search on what humans use.

python
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('order_id', 'customer_email', 'total_amount', 'status_badge', 'created_at')
    search_fields = ('order_id', 'customer__email', 'customer__name')
    
    def status_badge(self, obj):
        # ... status badge implementation

Now a support agent can type "alice@example.com" and find all their orders instantly. This removes the "let me check in the database" friction.

list_filter enables drill-down by status, date range, and other dimensions. Use it to segment data without queries.

python
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('order_id', 'customer_email', 'total_amount', 'status_badge', 'created_at')
    search_fields = ('order_id', 'customer__email', 'customer__name')
    list_filter = ('status', 'created_at', 'currency')
    date_hierarchy = 'created_at'

The date_hierarchy addition lets your team navigate by year, month, and day. For a high-volume SaaS, this is the difference between waiting 10 seconds for a list to load and loading it instantly with day-level scope.

Read-only fields: protecting sensitive data without hardening views

Your ops team needs to see payment details, API keys, and customer phone numbers to debug issues. But they shouldn't edit them directly—that's what your application logic is for. Read-only fields let you expose data safely.

python
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
    list_display = ('payment_id', 'order', 'amount', 'processor', 'status')
    readonly_fields = ('payment_id', 'transaction_hash', 'processor_fee', 
                       'created_at', 'last_webhook_at', 'webhook_status')
    fieldsets = (
        ('Payment Details', {
            'fields': ('payment_id', 'order', 'amount', 'currency', 'status')
        }),
        ('Processor Information', {
            'fields': ('processor', 'transaction_hash', 'processor_fee'),
            'classes': ('collapse',)
        }),
        ('Webhook Audit', {
            'fields': ('created_at', 'last_webhook_at', 'webhook_status'),
            'classes': ('collapse',)
        })
    )

This structure lets a support agent see the full picture—what the customer paid, what the processor charged, and whether we received webhooks—without accidentally editing the transaction hash or payment ID. The collapsed sections keep the form clean while preserving access when needed.

Use get_readonly_fields to make fields read-only conditionally. For instance, locked payment records should be fully read-only, but draft records might allow edits.

python
def get_readonly_fields(self, request, obj=None):
    if obj and obj.status == 'completed':
        return self.readonly_fields + ('order', 'amount')
    return self.readonly_fields

Custom actions for bulk operations: automating repetitive work

Your team shouldn't be clicking "edit" on 50 orders to mark them as shipped. Custom actions let them select records and process them in bulk.

python
from django.utils.translation import ngettext
from django.contrib import messages

def mark_as_shipped(modeladmin, request, queryset):
    updated = queryset.filter(status='confirmed').update(status='shipped')
    modeladmin.message_user(
        request,
        ngettext(
            '%d order was successfully marked as shipped.',
            '%d orders were successfully marked as shipped.',
            updated,
        ) % updated,
        messages.SUCCESS,
    )
mark_as_shipped.short_description = "Mark selected orders as shipped"

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('order_id', 'customer_email', 'total_amount', 'status_badge', 'created_at')
    search_fields = ('order_id', 'customer__email', 'customer__name')
    list_filter = ('status', 'created_at')
    actions = [mark_as_shipped]

Now a team member selects 30 confirmed orders, hits "Mark as shipped," and confirms. The action runs once. No spreadsheets, no scripts, no manual updates.

For complex operations, actions can redirect to a form page where you collect parameters.

python
def refund_selected(modeladmin, request, queryset):
    if request.POST.get('post') == 'yes':
        reason = request.POST.get('reason', 'No reason provided')
        updated = queryset.update(status='refunded', refund_reason=reason)
        modeladmin.message_user(request, f'{updated} orders refunded.')
        return
    
    return render(request, 'admin/refund_confirmation.html', {
        'orders': queryset,
        'action': 'refund_selected'
    })
refund_selected.short_description = "Refund selected orders with reason"

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    actions = [mark_as_shipped, refund_selected]

This pattern is powerful: the action collects a refund reason before processing, creating an audit trail and preventing accidental refunds with no justification.

Restrict with staff permissions: keeping the back office secure

Not everyone on your team should see everything. Django's permission system integrates directly into the admin. Use it.

python
from django.contrib.auth.decorators import user_passes_test
from django.utils.decorators import method_decorator

@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
    list_display = ('payment_id', 'order', 'amount', 'processor', 'status')
    readonly_fields = ('payment_id', 'transaction_hash', 'processor_fee', 
                       'created_at', 'last_webhook_at', 'webhook_status')
    
    def has_add_permission(self, request):
        return False  # Payments created by app logic only
    
    def has_delete_permission(self, request, obj=None):
        return False  # No deletion
    
    def has_change_permission(self, request, obj=None):
        return request.user.groups.filter(name='Finance').exists()
    
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        if not request.user.is_superuser:
            # Non-admins see only their organization's payments
            return qs.filter(order__organization=request.user.organization)
        return qs

This admin enforces several rules: payments can only be viewed and audited, never added or deleted directly; only Finance team members can access this admin; and non-superusers see only their organization's data.

For more granular control, use custom permissions tied to your business logic.

python
class Payment(models.Model):
    # ... fields
    
    class Meta:
        permissions = [
            ("can_refund_payment", "Can refund payments"),
            ("can_view_processor_fee", "Can view processor fees"),
        ]

@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
    def refund_action(self, request, queryset):
        if not request.user.has_perm('myapp.can_refund_payment'):
            raise PermissionDenied
        # ... refund logic
    refund_action.short_description = "Refund selected"
    
    actions = [refund_action]

Now refunds require explicit permission. Your support team might view payments, but only Finance can issue refunds. This separation keeps your audit logs clean and your compliance team happy.

Common mistakes

  • Exposing too much in list view: If list_display has 12 columns, the table is useless. Stick to 4–6 columns that answer "what is this record and what's its status?"
  • Searching on the wrong fields: Searching on internal ID when users think in order numbers or email addresses frustrates your team. Ask them how they actually look up records, then configure search fields to match.
  • Forgetting ordering: Add ordering = ('-created_at',) to your admin so newest records appear first. Without it, pagination becomes painful at scale.
  • Making actions too powerful: A bulk action that can't be undone (like delete) should require a confirmation form, not just a button click. Test it with your ops team first.
  • Leaving permissions wide open: "Everyone is staff" doesn't scale. Set up groups and permissions early, even if you only have five users now.
  • Ignoring list_select_related and prefetch_related: If your admin list makes N+1 queries, it will crawl at scale. Use these to batch load related objects.
python
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('order_id', 'customer_email', 'total_amount', 'status_badge', 'created_at')
    
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs.select_related('customer', 'payment').prefetch_related('items')

Key takeaways

  • Configure list_display, search_fields, and list_filter to make your ops team independent. They should find and understand records without asking engineers for queries.
  • Use readonly_fields to expose sensitive data safely. Let your team audit payment details and API interactions without risking accidental edits.
  • Build custom actions for repetitive bulk work. A "Mark as shipped" action saves your team hours a month and creates an audit trail.
  • Lock down permissions early. Use Django's permission system to separate concerns: viewing payments is fine, refunding them is not.
  • Test your admin with real team members. What feels logical to you might be confusing to someone using it daily under pressure.
  • Optimize queries with select_related and prefetch_related before load becomes a problem. The admin can scale to hundreds of thousands of records if you get this right.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles