Admin Site

Customizing Admin Display

Django's admin interface is highly customizable. This chapter covers advanced techniques for creating a polished, user-friendly admin experience tailored to your specific needs.

Customizing Admin Display

Django's admin interface is highly customizable. This chapter covers advanced techniques for creating a polished, user-friendly admin experience tailored to your specific needs.

List View Customization

Advanced List Display

from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from django.utils.safestring import mark_safe
from .models import Article, Category

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = [
        'thumbnail_preview',
        'title_with_link',
        'author_info',
        'category_badge',
        'status_indicator',
        'stats_summary',
        'action_buttons'
    ]
    
    def thumbnail_preview(self, obj):
        """Display article thumbnail"""
        if obj.featured_image:
            return format_html(
                '<img src="{}" width="60" height="40" style="border-radius: 4px; object-fit: cover;" />',
                obj.featured_image.url
            )
        return format_html('<div style="width: 60px; height: 40px; background: #f0f0f0; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #666;">No Image</div>')
    
    thumbnail_preview.short_description = 'Preview'
    
    def title_with_link(self, obj):
        """Title with link to public page"""
        if obj.is_published:
            return format_html(
                '<a href="{}" target="_blank" title="View on site">{}</a>',
                obj.get_absolute_url(),
                obj.title
            )
        return obj.title
    
    title_with_link.short_description = 'Title'
    title_with_link.admin_order_field = 'title'
    
    def author_info(self, obj):
        """Author with avatar and email"""
        if obj.author:
            return format_html(
                '<div style="display: flex; align-items: center;">'
                '<div style="width: 24px; height: 24px; border-radius: 50%; background: #007cba; color: white; display: flex; align-items: center; justify-content: center; font-size: 12px; margin-right: 8px;">{}</div>'
                '<div><strong>{}</strong><br><small>{}</small></div>'
                '</div>',
                obj.author.username[0].upper(),
                obj.author.get_full_name() or obj.author.username,
                obj.author.email
            )
        return '-'
    
    author_info.short_description = 'Author'
    
    def category_badge(self, obj):
        """Category with color coding"""
        if obj.category:
            colors = {
                'Technology': '#3498db',
                'Business': '#2ecc71',
                'Lifestyle': '#e74c3c',
                'Education': '#f39c12'
            }
            color = colors.get(obj.category.name, '#95a5a6')
            return format_html(
                '<span style="background: {}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold;">{}</span>',
                color,
                obj.category.name
            )
        return '-'
    
    category_badge.short_description = 'Category'
    
    def status_indicator(self, obj):
        """Status with visual indicators"""
        status_config = {
            'draft': {'color': '#f39c12', 'icon': '📝'},
            'published': {'color': '#27ae60', 'icon': ''},
            'archived': {'color': '#95a5a6', 'icon': '📦'}
        }
        config = status_config.get(obj.status, {'color': '#000', 'icon': ''})
        
        return format_html(
            '<span style="color: {}; font-weight: bold;">{} {}</span>',
            config['color'],
            config['icon'],
            obj.get_status_display()
        )
    
    status_indicator.short_description = 'Status'
    
    def stats_summary(self, obj):
        """Article statistics"""
        word_count = len(obj.content.split()) if obj.content else 0
        reading_time = max(1, word_count // 200)
        
        return format_html(
            '<div style="font-size: 11px; color: #666;">'
            '<div>📊 {} words</div>'
            '<div>⏱️ {} min read</div>'
            '<div>👀 {} views</div>'
            '</div>',
            word_count,
            reading_time,
            getattr(obj, 'view_count', 0)
        )
    
    stats_summary.short_description = 'Stats'
    
    def action_buttons(self, obj):
        """Quick action buttons"""
        buttons = []
        
        # Edit button
        edit_url = reverse('admin:myapp_article_change', args=[obj.pk])
        buttons.append(f'<a href="{edit_url}" style="margin-right: 5px;">✏️</a>')
        
        # View on site button (if published)
        if obj.is_published:
            buttons.append(f'<a href="{obj.get_absolute_url()}" target="_blank" style="margin-right: 5px;">👁️</a>')
        
        # Quick publish/unpublish
        if obj.status == 'draft':
            buttons.append('<a href="#" onclick="quickPublish({})" style="margin-right: 5px;">🚀</a>'.format(obj.pk))
        
        return format_html(''.join(buttons))
    
    action_buttons.short_description = 'Actions'

Custom List Filters

from django.contrib.admin import SimpleListFilter
from datetime import datetime, timedelta

class PublishedDateFilter(SimpleListFilter):
    """Custom filter for published date ranges"""
    title = 'published date'
    parameter_name = 'published_date'
    
    def lookups(self, request, model_admin):
        return (
            ('today', 'Today'),
            ('week', 'This week'),
            ('month', 'This month'),
            ('year', 'This year'),
            ('older', 'Older than 1 year'),
        )
    
    def queryset(self, request, queryset):
        now = datetime.now()
        
        if self.value() == 'today':
            return queryset.filter(
                published_at__date=now.date(),
                status='published'
            )
        elif self.value() == 'week':
            week_ago = now - timedelta(days=7)
            return queryset.filter(
                published_at__gte=week_ago,
                status='published'
            )
        elif self.value() == 'month':
            month_ago = now - timedelta(days=30)
            return queryset.filter(
                published_at__gte=month_ago,
                status='published'
            )
        elif self.value() == 'year':
            year_ago = now - timedelta(days=365)
            return queryset.filter(
                published_at__gte=year_ago,
                status='published'
            )
        elif self.value() == 'older':
            year_ago = now - timedelta(days=365)
            return queryset.filter(
                published_at__lt=year_ago,
                status='published'
            )

class AuthorProductivityFilter(SimpleListFilter):
    """Filter by author productivity"""
    title = 'author productivity'
    parameter_name = 'author_productivity'
    
    def lookups(self, request, model_admin):
        return (
            ('high', 'High (10+ articles)'),
            ('medium', 'Medium (5-9 articles)'),
            ('low', 'Low (1-4 articles)'),
            ('none', 'No articles'),
        )
    
    def queryset(self, request, queryset):
        from django.db.models import Count
        
        if self.value() == 'high':
            authors = User.objects.annotate(
                article_count=Count('article')
            ).filter(article_count__gte=10).values_list('id', flat=True)
            return queryset.filter(author__in=authors)
        # ... similar logic for other values

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_filter = [
        'status',
        'category',
        PublishedDateFilter,
        AuthorProductivityFilter,
        'created_at'
    ]

Form Customization

Custom Fieldsets

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    fieldsets = (
        ('Article Information', {
            'fields': ('title', 'slug', 'author', 'category'),
            'description': 'Basic article information and categorization.'
        }),
        ('Content', {
            'fields': ('excerpt', 'content', 'featured_image'),
            'classes': ('wide',),
            'description': 'Article content and media.'
        }),
        ('SEO & Metadata', {
            'fields': ('meta_title', 'meta_description', 'tags'),
            'classes': ('collapse',),
            'description': 'Search engine optimization and metadata.'
        }),
        ('Publishing', {
            'fields': ('status', 'published_at', 'featured'),
            'classes': ('wide',),
            'description': 'Publishing settings and scheduling.'
        }),
        ('Advanced Options', {
            'fields': ('allow_comments', 'template', 'custom_css'),
            'classes': ('collapse',),
            'description': 'Advanced configuration options.'
        })
    )
    
    readonly_fields = ['created_at', 'updated_at', 'view_count']

Custom Widgets

from django import forms
from django.contrib import admin

class ArticleAdminForm(forms.ModelForm):
    """Custom form for Article admin"""
    
    class Meta:
        model = Article
        fields = '__all__'
        widgets = {
            'content': forms.Textarea(attrs={
                'rows': 20,
                'cols': 80,
                'class': 'vLargeTextField'
            }),
            'excerpt': forms.Textarea(attrs={
                'rows': 4,
                'cols': 80,
                'placeholder': 'Brief description of the article...'
            }),
            'meta_description': forms.Textarea(attrs={
                'rows': 3,
                'cols': 80,
                'maxlength': 160,
                'placeholder': 'SEO meta description (max 160 characters)'
            }),
            'tags': forms.CheckboxSelectMultiple(),
        }
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        
        # Customize field properties
        self.fields['title'].help_text = 'Enter a compelling title for your article'
        self.fields['slug'].help_text = 'URL-friendly version of the title'
        
        # Add CSS classes
        self.fields['title'].widget.attrs.update({'class': 'vTextField'})
        self.fields['slug'].widget.attrs.update({'class': 'vTextField'})

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    form = ArticleAdminForm

Dynamic Form Fields

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    def get_form(self, request, obj=None, **kwargs):
        """Customize form based on user permissions and object state"""
        form = super().get_form(request, obj, **kwargs)
        
        # Restrict author field for non-superusers
        if not request.user.is_superuser:
            if 'author' in form.base_fields:
                form.base_fields['author'].queryset = User.objects.filter(
                    id=request.user.id
                )
                form.base_fields['author'].initial = request.user
        
        # Hide advanced fields for new articles
        if obj is None:  # Creating new article
            if 'custom_css' in form.base_fields:
                del form.base_fields['custom_css']
            if 'template' in form.base_fields:
                del form.base_fields['template']
        
        # Customize based on article status
        if obj and obj.status == 'published':
            if 'published_at' in form.base_fields:
                form.base_fields['published_at'].widget.attrs['readonly'] = True
        
        return form
    
    def get_readonly_fields(self, request, obj=None):
        """Dynamic readonly fields"""
        readonly_fields = list(self.readonly_fields)
        
        # Make certain fields readonly after publication
        if obj and obj.status == 'published':
            readonly_fields.extend(['slug', 'author'])
        
        # Non-superusers can't change author
        if not request.user.is_superuser and obj:
            readonly_fields.append('author')
        
        return readonly_fields

Inline Customization

Advanced Inline Configuration

class CommentInline(admin.TabularInline):
    """Inline for article comments"""
    model = Comment
    extra = 0
    fields = ['author_name', 'email', 'content_preview', 'is_approved', 'created_at']
    readonly_fields = ['author_name', 'email', 'content_preview', 'created_at']
    
    def content_preview(self, obj):
        """Show content preview"""
        if obj.content:
            preview = obj.content[:100]
            if len(obj.content) > 100:
                preview += '...'
            return preview
        return '-'
    
    content_preview.short_description = 'Content Preview'
    
    def has_add_permission(self, request, obj=None):
        return False  # Don't allow adding comments through inline

class ArticleImageInline(admin.StackedInline):
    """Inline for article images"""
    model = ArticleImage
    extra = 1
    fields = ['image_preview', 'image', 'caption', 'alt_text', 'order']
    readonly_fields = ['image_preview']
    
    def image_preview(self, obj):
        """Show image preview"""
        if obj.image:
            return format_html(
                '<img src="{}" width="200" style="border-radius: 4px;" />',
                obj.image.url
            )
        return 'No image uploaded'
    
    image_preview.short_description = 'Preview'

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    inlines = [ArticleImageInline, CommentInline]

Conditional Inlines

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    def get_inline_instances(self, request, obj=None):
        """Show different inlines based on conditions"""
        inline_instances = []
        
        # Always show image inline
        inline_instances.append(ArticleImageInline(self.model, self.admin_site))
        
        # Only show comments inline for published articles
        if obj and obj.status == 'published':
            inline_instances.append(CommentInline(self.model, self.admin_site))
        
        # Show SEO inline for superusers
        if request.user.is_superuser:
            inline_instances.append(SEOInline(self.model, self.admin_site))
        
        return inline_instances

Custom Admin Views

Adding Custom Views

from django.urls import path
from django.shortcuts import render, redirect
from django.contrib import messages
from django.http import JsonResponse

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    def get_urls(self):
        """Add custom URLs to admin"""
        urls = super().get_urls()
        custom_urls = [
            path('analytics/', self.admin_site.admin_view(self.analytics_view), name='article_analytics'),
            path('bulk-publish/', self.admin_site.admin_view(self.bulk_publish_view), name='article_bulk_publish'),
            path('<int:article_id>/preview/', self.admin_site.admin_view(self.preview_view), name='article_preview'),
        ]
        return custom_urls + urls
    
    def analytics_view(self, request):
        """Custom analytics view"""
        from django.db.models import Count, Avg
        
        stats = {
            'total_articles': Article.objects.count(),
            'published_articles': Article.objects.filter(status='published').count(),
            'draft_articles': Article.objects.filter(status='draft').count(),
            'avg_word_count': Article.objects.aggregate(
                avg_words=Avg('word_count')
            )['avg_words'] or 0,
            'articles_by_category': Article.objects.values('category__name').annotate(
                count=Count('id')
            ).order_by('-count')[:5]
        }
        
        context = {
            'title': 'Article Analytics',
            'stats': stats,
            'opts': self.model._meta,
        }
        
        return render(request, 'admin/article_analytics.html', context)
    
    def bulk_publish_view(self, request):
        """Bulk publish articles"""
        if request.method == 'POST':
            article_ids = request.POST.getlist('article_ids')
            updated = Article.objects.filter(
                id__in=article_ids,
                status='draft'
            ).update(status='published')
            
            messages.success(request, f'Published {updated} articles')
            return redirect('admin:myapp_article_changelist')
        
        # Show form with draft articles
        draft_articles = Article.objects.filter(status='draft')
        context = {
            'title': 'Bulk Publish Articles',
            'articles': draft_articles,
            'opts': self.model._meta,
        }
        
        return render(request, 'admin/bulk_publish.html', context)
    
    def preview_view(self, request, article_id):
        """Article preview"""
        article = self.get_object(request, article_id)
        if not article:
            raise Http404
        
        context = {
            'article': article,
            'title': f'Preview: {article.title}',
        }
        
        return render(request, 'admin/article_preview.html', context)

Custom Templates

<!-- templates/admin/article_analytics.html -->
{% extends "admin/base_site.html" %}
{% load static %}

{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}

{% block breadcrumbs %}
<div class="breadcrumbs">
    <a href="{% url 'admin:index' %}">Home</a>
    &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
    &rsaquo; <a href="{% url 'admin:myapp_article_changelist' %}">Articles</a>
    &rsaquo; Analytics
</div>
{% endblock %}

{% block content %}
<div class="module">
    <h2>Article Statistics</h2>
    
    <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0;">
        <div class="module">
            <h3>Total Articles</h3>
            <p style="font-size: 2em; font-weight: bold; color: #007cba;">{{ stats.total_articles }}</p>
        </div>
        
        <div class="module">
            <h3>Published</h3>
            <p style="font-size: 2em; font-weight: bold; color: #28a745;">{{ stats.published_articles }}</p>
        </div>
        
        <div class="module">
            <h3>Drafts</h3>
            <p style="font-size: 2em; font-weight: bold; color: #ffc107;">{{ stats.draft_articles }}</p>
        </div>
        
        <div class="module">
            <h3>Avg. Words</h3>
            <p style="font-size: 2em; font-weight: bold; color: #17a2b8;">{{ stats.avg_word_count|floatformat:0 }}</p>
        </div>
    </div>
    
    <div class="module">
        <h3>Top Categories</h3>
        <table>
            <thead>
                <tr>
                    <th>Category</th>
                    <th>Articles</th>
                </tr>
            </thead>
            <tbody>
                {% for category in stats.articles_by_category %}
                <tr>
                    <td>{{ category.category__name|default:"Uncategorized" }}</td>
                    <td>{{ category.count }}</td>
                </tr>
                {% endfor %}
            </tbody>
        </table>
    </div>
</div>
{% endblock %}

Advanced Customization Techniques

Custom CSS and JavaScript

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    class Media:
        css = {
            'all': ('admin/css/article_admin.css',)
        }
        js = ('admin/js/article_admin.js',)
/* static/admin/css/article_admin.css */
.field-thumbnail_preview img {
    transition: transform 0.2s;
}

.field-thumbnail_preview img:hover {
    transform: scale(1.1);
    cursor: pointer;
}

.status-published {
    background-color: #d4edda;
    border-left: 4px solid #28a745;
    padding: 10px;
}

.status-draft {
    background-color: #fff3cd;
    border-left: 4px solid #ffc107;
    padding: 10px;
}

.article-stats {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 10px;
    margin: 10px 0;
}

.stat-item {
    text-align: center;
    padding: 10px;
    background: #f8f9fa;
    border-radius: 4px;
}
// static/admin/js/article_admin.js
(function($) {
    $(document).ready(function() {
        // Auto-generate slug from title
        $('#id_title').on('input', function() {
            var title = $(this).val();
            var slug = title.toLowerCase()
                .replace(/[^\w\s-]/g, '')
                .replace(/\s+/g, '-');
            $('#id_slug').val(slug);
        });
        
        // Word count for content field
        function updateWordCount() {
            var content = $('#id_content').val();
            var wordCount = content.split(/\s+/).filter(function(word) {
                return word.length > 0;
            }).length;
            
            $('#word-count-display').text(wordCount + ' words');
        }
        
        $('#id_content').on('input', updateWordCount);
        
        // Add word count display
        $('#id_content').after('<div id="word-count-display" style="margin-top: 5px; color: #666;"></div>');
        updateWordCount();
        
        // Quick publish button
        function addQuickPublishButton() {
            var publishBtn = $('<button type="button" class="button" id="quick-publish">Quick Publish</button>');
            publishBtn.on('click', function() {
                $('#id_status').val('published');
                $('#id_published_at').val(new Date().toISOString().slice(0, 16));
                $(this).text('Published!').prop('disabled', true);
            });
            
            $('.submit-row').prepend(publishBtn);
        }
        
        if ($('#id_status').val() === 'draft') {
            addQuickPublishButton();
        }
    });
})(django.jQuery);

Performance Optimization

Efficient Querysets

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ['title', 'author', 'category', 'created_at']
    list_select_related = ['author', 'category']  # Reduce database queries
    
    def get_queryset(self, request):
        """Optimize queryset for admin list view"""
        queryset = super().get_queryset(request)
        return queryset.select_related(
            'author',
            'category'
        ).prefetch_related(
            'tags',
            'comments'
        ).annotate(
            comment_count=Count('comments')
        )
    
    def comment_count(self, obj):
        """Display comment count using annotation"""
        return obj.comment_count
    
    comment_count.short_description = 'Comments'
    comment_count.admin_order_field = 'comment_count'

Pagination and Limits

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_per_page = 25  # Limit items per page
    list_max_show_all = 100  # Maximum items for "show all"
    
    # For large datasets, disable "show all"
    show_full_result_count = False

Best Practices

User Experience

  • Use clear, descriptive field labels
  • Provide helpful tooltips and descriptions
  • Implement logical field grouping with fieldsets
  • Add visual indicators for status and importance

Performance

  • Optimize database queries with select_related and prefetch_related
  • Use appropriate pagination settings
  • Implement caching for expensive operations
  • Limit the number of items displayed per page

Maintainability

  • Keep admin customizations modular and reusable
  • Document complex admin logic
  • Use consistent naming conventions
  • Separate concerns between models, forms, and admin classes

Next Steps

Now that you've mastered admin display customization, let's explore admin actions to add powerful bulk operations to your admin interface.