Django Admin Interface Customization

Module 23: Web Frameworks II (Python) - Thursday, Lecture 1

Introduction to the Django Admin

The Django Admin is one of Django's most powerful features—a fully-featured administrative interface that's automatically generated from your models. It allows site administrators to view, add, edit, and delete records without writing a single line of code.

Key features of the Django Admin include:

The Control Panel Analogy

Think of the Django Admin as the control panel of a modern building. Just as a building's control panel provides authorized personnel with access to manage various systems (HVAC, security, lighting) without understanding the underlying electrical and mechanical systems, the Django Admin provides authorized users with an interface to manage application data without writing code or SQL queries. The beauty is that this control panel is automatically generated based on your models, ready to use with minimal configuration.

graph LR A[Django Models] -->|Register| B[Django Admin Site] B -->|Generate| C[Admin Interface] C -->|Manage| D[Database] E[Admin User] -->|Login| C C -->|Display| F[List View] C -->|Display| G[Detail View] C -->|Display| H[Form View] C -->|Display| I[Delete View]

Basic Admin Setup

To start using the Django Admin, you need to register your models with it. The simplest approach is to register a model directly:

1. Enable Admin in INSTALLED_APPS

Ensure that the admin app is enabled in your project's settings.py:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # ... your apps
]

2. Include Admin URLs

Make sure the admin URLs are included in your project's urls.py:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    # ... your URLs
]

3. Register Models

Register your models in your app's admin.py file:

from django.contrib import admin
from .models import Article, Category, Tag

# Basic registration
admin.site.register(Article)
admin.site.register(Category)
admin.site.register(Tag)

This minimal setup provides a complete admin interface for your models. However, it often doesn't provide the best user experience for complex models. That's where customization comes in.

Creating Custom ModelAdmin Classes

To customize how a model appears in the admin, create a custom ModelAdmin class:

from django.contrib import admin
from .models import Article, Category, Tag

class ArticleAdmin(admin.ModelAdmin):
    # Configure display of article list
    list_display = ('title', 'author', 'status', 'category', 'created_at', 'published_at')
    list_filter = ('status', 'category', 'created_at', 'author')
    search_fields = ('title', 'content')
    date_hierarchy = 'published_at'
    ordering = ('-created_at',)
    
    # Configure fields on the edit form
    fieldsets = (
        ('Basic Information', {
            'fields': ('title', 'slug', 'author', 'content')
        }),
        ('Publishing Options', {
            'fields': ('status', 'category', 'tags', 'published_at'),
            'classes': ('collapse',)
        }),
        ('Meta Information', {
            'fields': ('meta_description', 'meta_keywords'),
            'description': 'SEO metadata for the article'
        })
    )
    
    # Prepopulate slug field from title
    prepopulated_fields = {'slug': ('title',)}
    
    # Add raw_id_fields for better handling of foreign keys with many objects
    raw_id_fields = ('author',)
    
    # Configure related objects
    filter_horizontal = ('tags',)

# Register the model with the custom admin class
admin.site.register(Article, ArticleAdmin)

# Simpler models can use the @register decorator
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug')
    prepopulated_fields = {'slug': ('name',)}

@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug')
    prepopulated_fields = {'slug': ('name',)}

The ModelAdmin class provides numerous options for customizing the admin interface. Let's explore these options in detail.

List View Customization

The list view is the first screen you see when you click on a model in the admin. You can customize it in several ways:

Display Fields and Formatting

class ArticleAdmin(admin.ModelAdmin):
    # Basic list display
    list_display = ('title', 'author', 'status', 'category', 'created_at')
    
    # Add custom methods to list_display
    list_display = ('title', 'author', 'status', 'category', 'created_at', 'is_recent')
    
    def is_recent(self, obj):
        """Custom column that shows if the article is recent."""
        from django.utils import timezone
        return obj.created_at >= timezone.now() - timezone.timedelta(days=7)
    
    # Configure the custom method
    is_recent.short_description = 'Recent?'  # Column header
    is_recent.boolean = True  # Display as icon
    is_recent.admin_order_field = 'created_at'  # Allow sorting
    
    # Add clickable links to related models
    list_display_links = ('title', 'slug')
    
    # Add editable fields (edit without going to the detail page)
    list_editable = ('status', 'category')
    
    # Custom empty value display
    empty_value_display = '-empty-'

Filtering and Searching

class ArticleAdmin(admin.ModelAdmin):
    # Add filters in the right sidebar
    list_filter = ('status', 'category', 'created_at', 'author')
    
    # Add custom filters
    list_filter = ('status', 'category', CategoryYearFilter, 'author')
    
    # Add search functionality
    search_fields = ('title', 'content')
    
    # Add related field search
    search_fields = ('title', 'content', 'author__username', 'author__email')
    
    # Add date-based navigation
    date_hierarchy = 'published_at'

Custom List Filters

from django.contrib.admin import SimpleListFilter

class CategoryYearFilter(SimpleListFilter):
    """Custom filter to filter by year and category."""
    title = 'Year and Category'
    parameter_name = 'year_category'
    
    def lookups(self, request, model_admin):
        """Return a list of tuples (value, verbose name)."""
        # Get all years with articles
        years = Article.objects.dates('created_at', 'year')
        # Get all categories
        categories = Category.objects.all()
        
        # Create tuples for each combination
        lookups = []
        for year in years:
            year_str = str(year.year)
            lookups.append((year_str, year_str))
            for category in categories:
                key = f"{year.year}-{category.id}"
                value = f"{year.year} - {category.name}"
                lookups.append((key, value))
        
        return lookups
    
    def queryset(self, request, queryset):
        """Filter the queryset based on the selected value."""
        value = self.value()
        if not value:
            return queryset
        
        # Check if value is just a year
        if value.isdigit():
            return queryset.filter(created_at__year=int(value))
        
        # Value is year-category
        year, category_id = value.split('-')
        return queryset.filter(
            created_at__year=int(year),
            category_id=int(category_id)
        )

Pagination and Ordering

class ArticleAdmin(admin.ModelAdmin):
    # Set number of items per page
    list_per_page = 25
    
    # Default ordering
    ordering = ('-created_at',)
    
    # Show all fields in the changelist view
    list_max_show_all = 1000  # Maximum allowed to show with 'show all'
    
    # Preserve filters when navigating to detail view and back
    preserve_filters = True

Detail View Customization

The detail view (or "change form") is where you edit an individual object. You can customize how fields are displayed and grouped:

Field Organization with Fieldsets

class ArticleAdmin(admin.ModelAdmin):
    # Basic fieldsets
    fieldsets = (
        ('Basic Information', {
            'fields': ('title', 'slug', 'author', 'content')
        }),
        ('Publishing Options', {
            'fields': ('status', 'category', 'tags', 'published_at'),
            'classes': ('collapse',)  # Collapsed by default
        }),
        ('Meta Information', {
            'fields': ('meta_description', 'meta_keywords'),
            'description': 'SEO metadata for the article'
        })
    )
    
    # Exclude fields from the form
    exclude = ('views_count', 'last_modified')
    
    # Read-only fields
    readonly_fields = ('created_at', 'updated_at')
    
    # Automatically populate fields
    prepopulated_fields = {'slug': ('title',)}

Inline Related Objects

Inlines allow you to edit related objects on the same page as the parent object:

# Define inline admin classes
class CommentInline(admin.TabularInline):
    model = Comment
    extra = 1  # Number of empty forms to display
    fields = ('author', 'content', 'approved')
    readonly_fields = ('created_at',)
    
class ImageInline(admin.StackedInline):
    model = ArticleImage
    extra = 3
    fields = ('image', 'caption', 'order')

class ArticleAdmin(admin.ModelAdmin):
    # ... other options
    
    # Add inlines to the detail view
    inlines = [ImageInline, CommentInline]

There are two types of inlines:

Foreign Key Handling

class ArticleAdmin(admin.ModelAdmin):
    # Use raw_id_fields for better handling of foreign keys with many objects
    raw_id_fields = ('author',)
    
    # Filter options for ForeignKey fields in forms
    autocomplete_fields = ('category', 'tags')
    
    # Many-to-many field display
    filter_horizontal = ('tags',)  # Horizontal selection widget
    # Or
    filter_vertical = ('tags',)    # Vertical selection widget

Custom Actions

Actions allow you to perform operations on multiple objects at once. Django provides a "delete selected objects" action by default, but you can add your own:

class ArticleAdmin(admin.ModelAdmin):
    # List of actions
    actions = ['make_published', 'make_draft', 'export_as_csv']
    
    def make_published(self, request, queryset):
        """Action to mark selected articles as published."""
        updated = queryset.update(status='published')
        self.message_user(
            request,
            f'{updated} article{"s" if updated != 1 else ""} were marked as published.'
        )
    make_published.short_description = 'Mark selected articles as published'
    
    def make_draft(self, request, queryset):
        """Action to mark selected articles as draft."""
        updated = queryset.update(status='draft')
        self.message_user(
            request,
            f'{updated} article{"s" if updated != 1 else ""} were marked as draft.'
        )
    make_draft.short_description = 'Mark selected articles as draft'
    
    def export_as_csv(self, request, queryset):
        """Action to export selected articles as CSV."""
        import csv
        from django.http import HttpResponse
        
        # Create a response with CSV header
        response = HttpResponse(content_type='text/csv')
        response['Content-Disposition'] = 'attachment; filename=articles.csv'
        
        # Create CSV writer
        writer = csv.writer(response)
        
        # Write headers
        writer.writerow(['Title', 'Author', 'Status', 'Category', 'Created At'])
        
        # Write data
        for article in queryset:
            writer.writerow([
                article.title,
                article.author.username,
                article.status,
                article.category.name if article.category else 'None',
                article.created_at.strftime('%Y-%m-%d')
            ])
        
        return response
    export_as_csv.short_description = 'Export selected articles as CSV'

Action Permissions

You can restrict who can use an action:

class ArticleAdmin(admin.ModelAdmin):
    # ... other options and actions
    
    def has_make_published_permission(self, request):
        """Check if the user has permission to publish articles."""
        return request.user.has_perm('blog.can_publish')
    
    def has_export_as_csv_permission(self, request):
        """Check if the user has permission to export articles."""
        return request.user.is_superuser

Admin Display Hooks

You can customize how model instances are displayed in the admin:

class Article(models.Model):
    # ... fields
    
    def __str__(self):
        """String representation of the article."""
        return self.title
    
    def get_absolute_url(self):
        """Get the URL of the article."""
        from django.urls import reverse
        return reverse('blog:article_detail', kwargs={'slug': self.slug})
    
    class Meta:
        verbose_name = 'Article'
        verbose_name_plural = 'Articles'

And in your ModelAdmin class:

class ArticleAdmin(admin.ModelAdmin):
    # ... other options
    
    # Show a "View on site" button
    view_on_site = True  # Uses get_absolute_url
    
    # Or define a custom view_on_site method
    def view_on_site(self, obj):
        return f"/blog/{obj.slug}/"

Admin Form Customization

You can customize the forms used in the admin interface:

Custom Form Classes

from django import forms
from .models import Article

class ArticleAdminForm(forms.ModelForm):
    """Custom form for Article admin."""
    
    # Add a custom field
    notes = forms.CharField(
        widget=forms.Textarea,
        required=False,
        help_text='Notes for editors (not published)'
    )
    
    # Add additional validation
    def clean_title(self):
        """Validate the title field."""
        title = self.cleaned_data['title']
        if 'banned word' in title.lower():
            raise forms.ValidationError('Title contains banned words')
        return title
    
    # Configure the form
    class Meta:
        model = Article
        fields = '__all__'
        widgets = {
            'content': forms.Textarea(attrs={'rows': 30, 'cols': 80}),
            'meta_description': forms.Textarea(attrs={'rows': 3}),
        }

class ArticleAdmin(admin.ModelAdmin):
    # Use the custom form
    form = ArticleAdminForm

Custom Save Methods

class ArticleAdmin(admin.ModelAdmin):
    # ... other options
    
    def save_model(self, request, obj, form, change):
        """Custom save method."""
        # Set the author if not set
        if not obj.author:
            obj.author = request.user
        
        # Log the change
        if change:
            self.log_change(request, obj, 'Changed by admin')
        else:
            self.log_addition(request, obj, 'Added by admin')
        
        # Call the parent method to save
        super().save_model(request, obj, form, change)
    
    def save_formset(self, request, form, formset, change):
        """Custom save method for inline formsets."""
        instances = formset.save(commit=False)
        
        # Process each instance in the formset
        for instance in instances:
            # Set attributes if needed
            if not instance.created_by:
                instance.created_by = request.user
            
            # Save the instance
            instance.save()
        
        # Call delete() on objects to be deleted
        for obj in formset.deleted_objects:
            obj.delete()
        
        # Call the parent method
        super().save_formset(request, form, formset, change)

Admin Templates Customization

Django allows you to override admin templates to change its appearance and behavior:

Template Hierarchy

Django looks for admin templates in the following order:

  1. app_label/admin/model_name/action.html
  2. app_label/admin/action.html
  3. admin/model_name/action.html
  4. admin/action.html

For example, to customize the change form for the Article model in the blog app:

  1. blog/admin/article/change_form.html
  2. blog/admin/change_form.html
  3. admin/article/change_form.html
  4. admin/change_form.html

Example: Customizing the Change Form

Create a file at templates/admin/blog/article/change_form.html:

{% extends "admin/change_form.html" %}
{% load static %}

{% block extrahead %}
    {{ block.super }}
    <link rel="stylesheet" href="{% static 'admin/css/article_admin.css' %}">
    <script src="{% static 'admin/js/article_admin.js' %}"></script>
{% endblock %}

{% block form_top %}
    <div class="article-preview-warning">
        <p>Remember to preview articles before publishing!</p>
        {% if original %}
            <a href="{{ original.get_absolute_url }}" target="_blank" class="button">Preview</a>
        {% endif %}
    </div>
{% endblock %}

{% block submit_buttons_bottom %}
    {{ block.super }}
    <div class="submit-row">
        <input type="submit" value="Save as Draft" name="_save_as_draft" class="default" />
    </div>
{% endblock %}

Then handle the custom submit button in the ArticleAdmin class:

class ArticleAdmin(admin.ModelAdmin):
    # ... other options
    
    def save_model(self, request, obj, form, change):
        """Custom save method that handles the custom submit button."""
        if '_save_as_draft' in request.POST:
            obj.status = 'draft'
        
        super().save_model(request, obj, form, change)

Example: Custom Admin Index

Create a file at templates/admin/index.html:

{% extends "admin/index.html" %}
{% load static %}

{% block extrastyle %}
    {{ block.super }}
    <link rel="stylesheet" href="{% static 'admin/css/custom_dashboard.css' %}">
{% endblock %}

{% block content %}
    <div class="admin-dashboard-header">
        <h1>Welcome to {{ site_title }}</h1>
        <p>Today is {{ current_date|date }} and you are logged in as {{ user.username }}.</p>
        
        <div class="dashboard-stats">
            <div class="stat-box">
                <h3>Articles</h3>
                <p class="stat-number">{{ article_count }}</p>
            </div>
            <div class="stat-box">
                <h3>Users</h3>
                <p class="stat-number">{{ user_count }}</p>
            </div>
            <div class="stat-box">
                <h3>Comments</h3>
                <p class="stat-number">{{ comment_count }}</p>
            </div>
        </div>
    </div>
    
    {{ block.super }}
{% endblock %}

Then provide the context variables in your custom admin site class (see next section).

Customizing the Admin Site

You can create a custom AdminSite class to completely customize the admin interface:

from django.contrib.admin import AdminSite
from django.utils import timezone

class BlogAdminSite(AdminSite):
    # Customize site attributes
    site_header = 'Blog Administration'
    site_title = 'Blog Admin Portal'
    index_title = 'Blog Management'
    
    # Custom view for the index page
    def index(self, request, extra_context=None):
        """Customize the admin index page."""
        from .models import Article, Comment
        from django.contrib.auth.models import User
        
        # Add extra context
        if extra_context is None:
            extra_context = {}
        
        extra_context.update({
            'current_date': timezone.now(),
            'article_count': Article.objects.count(),
            'user_count': User.objects.count(),
            'comment_count': Comment.objects.count(),
        })
        
        return super().index(request, extra_context)
    
    # Custom authentication method
    def has_permission(self, request):
        """Check if the user has permission to access the admin site."""
        if not super().has_permission(request):
            return False
        
        # Only allow staff with specific permissions
        return request.user.has_perm('blog.access_admin')

# Create an instance of the custom admin site
blog_admin_site = BlogAdminSite(name='blog_admin')

# Register models with the custom admin site
blog_admin_site.register(Article, ArticleAdmin)
blog_admin_site.register(Category, CategoryAdmin)
blog_admin_site.register(Tag, TagAdmin)

Then update your project's urls.py to use the custom admin site:

from django.urls import path
from blog.admin import blog_admin_site

urlpatterns = [
    path('admin/', blog_admin_site.urls),
    # ... other URLs
]

Multiple Admin Sites

You can create multiple admin sites for different purposes:

# Create different admin sites
admin_site = AdminSite(name='admin')
staff_admin_site = AdminSite(name='staff_admin')
staff_admin_site.site_header = 'Staff Administration'

# Register models with different admin sites
admin_site.register(User, UserAdmin)
admin_site.register(Article, ArticleAdmin)
admin_site.register(Category, CategoryAdmin)

# Staff can only manage content
staff_admin_site.register(Article, StaffArticleAdmin)
staff_admin_site.register(Category, CategoryAdmin)

# In urls.py
urlpatterns = [
    path('admin/', admin_site.urls),
    path('staff-admin/', staff_admin_site.urls),
]

Admin Interface Styling

You can customize the look and feel of the admin interface with CSS and JavaScript:

Adding CSS and JavaScript

Create a file templates/admin/base_site.html:

{% extends "admin/base_site.html" %}
{% load static %}

{% block extrastyle %}
    {{ block.super }}
    <link rel="stylesheet" href="{% static 'admin/css/custom_admin.css' %}">
{% endblock %}

{% block extrahead %}
    {{ block.super }}
    <script src="{% static 'admin/js/custom_admin.js' %}"></script>
{% endblock %}

Create the CSS file at static/admin/css/custom_admin.css:

/* Custom Admin CSS */

/* Change header color */
#header {
    background: #2c3e50;
    color: #ecf0f1;
}

/* Style the sidebar */
.module h2, .module caption, .inline-group h2 {
    background: #34495e;
}

/* Style buttons */
.button, input[type=submit], input[type=button], .submit-row input, a.button {
    background: #2980b9;
}

/* Style primary buttons */
.button.default, input[type=submit].default, .submit-row input.default {
    background: #27ae60;
}

/* Add custom styling for specific admin pages */
body.app-blog.model-article .submit-row {
    background: #f9f9f9;
    border-top: 1px solid #eee;
}

/* Style list view */
#changelist table thead th {
    background: #3498db;
    color: #fff;
}

/* Style selected row */
#changelist .selected {
    background-color: #e74c3c;
}

/* Custom dashboard stats */
.dashboard-stats {
    display: flex;
    justify-content: space-between;
    margin-bottom: 20px;
}

.stat-box {
    background: #fff;
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 15px;
    width: 30%;
    text-align: center;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.stat-number {
    font-size: 36px;
    font-weight: bold;
    color: #2980b9;
}

Create the JavaScript file at static/admin/js/custom_admin.js:

// Custom Admin JavaScript

document.addEventListener('DOMContentLoaded', function() {
    // Add confirmation for publishing articles
    const publishButtons = document.querySelectorAll('input[name="_publish"]');
    publishButtons.forEach(button => {
        button.addEventListener('click', function(e) {
            if (!confirm('Are you sure you want to publish this article?')) {
                e.preventDefault();
            }
        });
    });
    
    // Add WYSIWYG editor to content field
    if (document.getElementById('id_content')) {
        // Initialize a rich text editor (example)
        // Note: This requires a WYSIWYG library to be included
        // For example: ClassicEditor.create(document.getElementById('id_content'));
    }
    
    // Add date picker to date fields
    const dateFields = document.querySelectorAll('.vDateField');
    dateFields.forEach(field => {
        // Add date picker functionality
        // For example: flatpickr(field, { dateFormat: 'Y-m-d' });
    });
});

Real-World Example: Complete Blog Admin

Let's put everything together in a comprehensive example for a blog application:

from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from django.db.models import Count
from .models import Article, Category, Tag, Comment, Image

# Register the admin site
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug', 'article_count')
    prepopulated_fields = {'slug': ('name',)}
    search_fields = ('name',)
    
    def article_count(self, obj):
        """Count articles in this category."""
        return obj.articles.count()
    article_count.short_description = 'Articles'

@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug', 'article_count')
    prepopulated_fields = {'slug': ('name',)}
    search_fields = ('name',)
    
    def article_count(self, obj):
        """Count articles with this tag."""
        return obj.articles.count()
    article_count.short_description = 'Articles'

class CommentInline(admin.TabularInline):
    model = Comment
    extra = 0
    fields = ('author', 'content', 'approved', 'created_at')
    readonly_fields = ('created_at',)
    
    def has_add_permission(self, request, obj=None):
        return False  # Prevent adding comments through the admin

class ImageInline(admin.TabularInline):
    model = Image
    extra = 1
    fields = ('image', 'caption', 'order', 'display_image')
    readonly_fields = ('display_image',)
    
    def display_image(self, obj):
        """Display a thumbnail of the image."""
        if obj.image:
            return format_html(
                '<img src="{}" width="100" height="100" style="object-fit: cover;" />',
                obj.image.url
            )
        return 'No Image'
    display_image.short_description = 'Thumbnail'

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    # List view options
    list_display = ('title', 'author', 'category', 'status', 'view_count', 'tag_list', 'created_at', 'admin_actions')
    list_filter = ('status', 'category', 'tags', 'created_at')
    search_fields = ('title', 'content', 'author__username', 'author__email')
    date_hierarchy = 'created_at'
    list_per_page = 20
    
    # Form options
    fieldsets = (
        ('Content', {
            'fields': ('title', 'slug', 'author', 'content', 'excerpt'),
        }),
        ('Publishing', {
            'fields': ('status', 'category', 'tags', 'featured_image', 'published_at'),
            'classes': ('collapse',),
        }),
        ('Statistics', {
            'fields': ('view_count', 'created_at', 'updated_at'),
            'classes': ('collapse',),
        }),
        ('SEO', {
            'fields': ('meta_title', 'meta_description', 'meta_keywords'),
            'classes': ('collapse',),
            'description': 'Search engine optimization fields',
        }),
    )
    readonly_fields = ('created_at', 'updated_at', 'view_count')
    prepopulated_fields = {'slug': ('title',)}
    filter_horizontal = ('tags',)
    raw_id_fields = ('author',)
    inlines = [ImageInline, CommentInline]
    
    # Custom actions
    actions = ['make_published', 'make_draft', 'export_as_csv']
    
    def get_queryset(self, request):
        """Optimize the queryset for the admin list view."""
        queryset = super().get_queryset(request)
        queryset = queryset.select_related('author', 'category')
        queryset = queryset.prefetch_related('tags')
        return queryset
    
    def tag_list(self, obj):
        """Display a list of tags as links."""
        return format_html(
            ', '.join(
                f'<a href="{reverse("admin:blog_tag_change", args=[tag.pk])}">{tag.name}</a>'
                for tag in obj.tags.all()
            )
        )
    tag_list.short_description = 'Tags'
    
    def admin_actions(self, obj):
        """Display action buttons."""
        if obj.status == 'draft':
            return format_html(
                '<a class="button" href="{}">Publish</a>',
                reverse('admin:publish_article', args=[obj.pk])
            )
        return format_html(
            '<a class="button" href="{}">View</a>',
            obj.get_absolute_url()
        )
    admin_actions.short_description = 'Actions'
    
    def make_published(self, request, queryset):
        """Mark selected articles as published."""
        updated = queryset.update(status='published')
        self.message_user(
            request,
            f'{updated} article{"s" if updated != 1 else ""} marked as published.'
        )
    make_published.short_description = 'Mark selected articles as published'
    
    def make_draft(self, request, queryset):
        """Mark selected articles as draft."""
        updated = queryset.update(status='draft')
        self.message_user(
            request,
            f'{updated} article{"s" if updated != 1 else ""} marked as draft.'
        )
    make_draft.short_description = 'Mark selected articles as draft'
    
    def export_as_csv(self, request, queryset):
        """Export selected articles as CSV."""
        import csv
        from django.http import HttpResponse
        
        response = HttpResponse(content_type='text/csv')
        response['Content-Disposition'] = 'attachment; filename=articles.csv'
        
        writer = csv.writer(response)
        writer.writerow(['Title', 'Author', 'Category', 'Status', 'Created At', 'Tags'])
        
        for article in queryset:
            writer.writerow([
                article.title,
                article.author.username,
                article.category.name if article.category else '',
                article.status,
                article.created_at.strftime('%Y-%m-%d'),
                ', '.join(tag.name for tag in article.tags.all())
            ])
        
        return response
    export_as_csv.short_description = 'Export selected articles as CSV'
    
    def get_urls(self):
        """Add custom URLs to the admin."""
        from django.urls import path
        urls = super().get_urls()
        custom_urls = [
            path(
                '/publish/',
                self.admin_site.admin_view(self.publish_article),
                name='publish_article',
            ),
        ]
        return custom_urls + urls
    
    def publish_article(self, request, article_id):
        """Custom view to publish an article."""
        from django.shortcuts import get_object_or_404, redirect
        from django.contrib import messages
        from django.utils import timezone
        
        article = get_object_or_404(Article, id=article_id)
        article.status = 'published'
        article.published_at = timezone.now()
        article.save()
        
        messages.success(request, f'Article "{article.title}" has been published.')
        return redirect('admin:blog_article_changelist')
    
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
    list_display = ('author', 'article', 'created_at', 'approved', 'content_excerpt')
    list_filter = ('approved', 'created_at')
    search_fields = ('author__username', 'content', 'article__title')
    date_hierarchy = 'created_at'
    readonly_fields = ('created_at',)
    actions = ['approve_comments', 'reject_comments']
    
    def content_excerpt(self, obj):
        """Display an excerpt of the comment content."""
        if len(obj.content) > 50:
            return obj.content[:50] + '...'
        return obj.content
    content_excerpt.short_description = 'Content'
    
    def approve_comments(self, request, queryset):
        """Approve selected comments."""
        updated = queryset.update(approved=True)
        self.message_user(
            request,
            f'{updated} comment{"s" if updated != 1 else ""} approved.'
        )
    approve_comments.short_description = 'Approve selected comments'
    
    def reject_comments(self, request, queryset):
        """Reject selected comments."""
        updated = queryset.update(approved=False)
        self.message_user(
            request,
            f'{updated} comment{"s" if updated != 1 else ""} rejected.'
        )
    reject_comments.short_description = 'Reject selected comments'

# Customize the admin site
admin.site.site_header = 'Blog Administration'
admin.site.site_title = 'Blog Admin'
admin.site.index_title = 'Blog Management'

This comprehensive example includes:

Best Practices for Admin Customization

Here are some best practices to follow when customizing the Django Admin:

Organization

Security

Usability

Maintenance

Practice Activity

Create a custom admin interface for an e-commerce application with the following models:

Your admin customization should include:

  1. Custom ModelAdmin classes for each model with appropriate list displays, filters, and search fields
  2. Custom actions for common operations (e.g., mark orders as shipped, mark products as featured)
  3. Inline editing for related models (e.g., order items in order)
  4. Custom display methods (e.g., thumbnail display for product images)
  5. Fieldsets to organize forms
  6. Custom list filters (e.g., filter products by price range)
  7. Admin site customization (site header, title, etc.)

Bonus challenges:

Further Topics to Explore