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.
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'
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'
]
@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']
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
@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
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]
@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
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)
<!-- 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>
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
› <a href="{% url 'admin:myapp_article_changelist' %}">Articles</a>
› 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 %}
@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);
@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'
@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
Now that you've mastered admin display customization, let's explore admin actions to add powerful bulk operations to your admin interface.
Registering Models
To make your models available in the Django admin interface, you need to register them. This chapter covers various ways to register models and basic configuration options.
Admin Actions
Admin actions provide a powerful way to perform bulk operations on selected objects in the Django admin interface. This chapter covers creating custom actions, handling complex operations, and building efficient bulk processing tools.