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.
Django provides a default "Delete selected" action:
from django.contrib import admin
from .models import Article
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ['title', 'status', 'author', 'created_at']
# The delete action is enabled by default
# To disable it:
# actions = None
from django.contrib import admin
from django.contrib import messages
from .models import Article
def make_published(modeladmin, request, queryset):
"""Publish selected articles"""
updated = queryset.update(status='published')
messages.success(
request,
f'{updated} articles were successfully published.'
)
make_published.short_description = "Mark selected articles as published"
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ['title', 'status', 'author', 'created_at']
actions = [make_published]
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ['title', 'status', 'author', 'created_at']
def make_published(self, request, queryset):
"""Publish selected articles"""
updated = queryset.update(status='published')
self.message_user(
request,
f'{updated} articles were successfully published.',
messages.SUCCESS
)
make_published.short_description = "Mark selected articles as published"
actions = ['make_published']
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
def make_published(self, request, queryset):
"""Publish selected articles"""
from datetime import datetime
# Update status and set published date
updated = 0
for article in queryset:
if article.status != 'published':
article.status = 'published'
article.published_at = datetime.now()
article.save()
updated += 1
self.message_user(
request,
f'{updated} articles were published.',
messages.SUCCESS
)
def make_draft(self, request, queryset):
"""Convert selected articles to draft"""
updated = queryset.exclude(status='draft').update(
status='draft',
published_at=None
)
self.message_user(
request,
f'{updated} articles were converted to draft.',
messages.SUCCESS
)
def archive_articles(self, request, queryset):
"""Archive selected articles"""
updated = queryset.update(status='archived')
self.message_user(
request,
f'{updated} articles were archived.',
messages.SUCCESS
)
make_published.short_description = "Publish selected articles"
make_draft.short_description = "Convert to draft"
archive_articles.short_description = "Archive selected articles"
actions = ['make_published', 'make_draft', 'archive_articles']
import csv
from django.http import HttpResponse
from datetime import datetime
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
def export_as_csv(self, request, queryset):
"""Export selected articles as CSV"""
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="articles_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv"'
writer = csv.writer(response)
# Write header
writer.writerow([
'ID', 'Title', 'Author', 'Category', 'Status',
'Created', 'Published', 'Word Count'
])
# Write data
for article in queryset:
writer.writerow([
article.id,
article.title,
article.author.username if article.author else '',
article.category.name if article.category else '',
article.get_status_display(),
article.created_at.strftime('%Y-%m-%d %H:%M:%S'),
article.published_at.strftime('%Y-%m-%d %H:%M:%S') if article.published_at else '',
len(article.content.split()) if article.content else 0
])
return response
def export_as_json(self, request, queryset):
"""Export selected articles as JSON"""
import json
data = []
for article in queryset:
data.append({
'id': article.id,
'title': article.title,
'author': article.author.username if article.author else None,
'category': article.category.name if article.category else None,
'status': article.status,
'content': article.content,
'created_at': article.created_at.isoformat(),
'published_at': article.published_at.isoformat() if article.published_at else None,
})
response = HttpResponse(
json.dumps(data, indent=2),
content_type='application/json'
)
response['Content-Disposition'] = f'attachment; filename="articles_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json"'
return response
export_as_csv.short_description = "Export selected articles as CSV"
export_as_json.short_description = "Export selected articles as JSON"
actions = ['export_as_csv', 'export_as_json']
from django.core.mail import send_mass_mail
from django.template.loader import render_to_string
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
def notify_authors(self, request, queryset):
"""Send notification emails to article authors"""
emails = []
authors_notified = set()
for article in queryset:
if article.author and article.author.email:
if article.author.id not in authors_notified:
# Prepare email content
subject = 'Article Update Notification'
message = render_to_string('admin/email/author_notification.txt', {
'author': article.author,
'article': article,
'admin_user': request.user
})
emails.append((
subject,
message,
'noreply@example.com',
[article.author.email]
))
authors_notified.add(article.author.id)
if emails:
send_mass_mail(emails, fail_silently=False)
self.message_user(
request,
f'Notifications sent to {len(emails)} authors.',
messages.SUCCESS
)
else:
self.message_user(
request,
'No authors with email addresses found.',
messages.WARNING
)
notify_authors.short_description = "Notify authors via email"
actions = ['notify_authors']
from django.shortcuts import render
from django.contrib.admin import helpers
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
def delete_with_confirmation(self, request, queryset):
"""Delete articles with custom confirmation page"""
if 'apply' in request.POST:
# User confirmed deletion
count = queryset.count()
queryset.delete()
self.message_user(
request,
f'{count} articles were successfully deleted.',
messages.SUCCESS
)
return None
# Show confirmation page
context = {
'title': 'Delete Articles',
'queryset': queryset,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'opts': self.model._meta,
}
return render(
request,
'admin/delete_confirmation.html',
context
)
delete_with_confirmation.short_description = "Delete selected articles (with confirmation)"
actions = ['delete_with_confirmation']
from django import forms
from django.shortcuts import render, redirect
class BulkCategoryForm(forms.Form):
"""Form for bulk category assignment"""
category = forms.ModelChoiceField(
queryset=Category.objects.all(),
empty_label="Select a category",
required=True
)
overwrite_existing = forms.BooleanField(
required=False,
initial=False,
help_text="Check to overwrite existing categories"
)
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
def assign_category(self, request, queryset):
"""Assign category to selected articles"""
if 'apply' in request.POST:
form = BulkCategoryForm(request.POST)
if form.is_valid():
category = form.cleaned_data['category']
overwrite = form.cleaned_data['overwrite_existing']
if overwrite:
updated = queryset.update(category=category)
else:
updated = queryset.filter(category__isnull=True).update(category=category)
self.message_user(
request,
f'{updated} articles were assigned to category "{category}".',
messages.SUCCESS
)
return redirect(request.get_full_path())
else:
form = BulkCategoryForm()
context = {
'title': 'Assign Category',
'form': form,
'queryset': queryset,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'opts': self.model._meta,
}
return render(request, 'admin/assign_category.html', context)
assign_category.short_description = "Assign category to selected articles"
actions = ['assign_category']
from celery import shared_task
from django.contrib import messages
@shared_task
def process_articles_async(article_ids, action_type, user_id):
"""Process articles asynchronously"""
from django.contrib.auth.models import User
articles = Article.objects.filter(id__in=article_ids)
user = User.objects.get(id=user_id)
if action_type == 'generate_thumbnails':
for article in articles:
if article.featured_image:
# Generate thumbnails
article.generate_thumbnails()
elif action_type == 'update_seo':
for article in articles:
# Update SEO metadata
article.update_seo_metadata()
# Send notification email when complete
send_mail(
'Bulk Action Complete',
f'Your bulk action "{action_type}" has been completed for {articles.count()} articles.',
'noreply@example.com',
[user.email]
)
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
def generate_thumbnails(self, request, queryset):
"""Generate thumbnails for selected articles (async)"""
article_ids = list(queryset.values_list('id', flat=True))
# Start background task
process_articles_async.delay(
article_ids,
'generate_thumbnails',
request.user.id
)
self.message_user(
request,
f'Thumbnail generation started for {len(article_ids)} articles. You will receive an email when complete.',
messages.INFO
)
generate_thumbnails.short_description = "Generate thumbnails (background task)"
actions = ['generate_thumbnails']
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
def get_actions(self, request):
"""Return actions based on user permissions"""
actions = super().get_actions(request)
# Remove delete action for non-superusers
if not request.user.is_superuser:
if 'delete_selected' in actions:
del actions['delete_selected']
# Only show publish action to users with publish permission
if not request.user.has_perm('myapp.can_publish_article'):
if 'make_published' in actions:
del actions['make_published']
return actions
def make_published(self, request, queryset):
"""Publish articles (requires permission)"""
if not request.user.has_perm('myapp.can_publish_article'):
self.message_user(
request,
'You do not have permission to publish articles.',
messages.ERROR
)
return
updated = queryset.update(status='published')
self.message_user(
request,
f'{updated} articles were published.',
messages.SUCCESS
)
make_published.short_description = "Publish selected articles"
actions = ['make_published']
from django.contrib.admin.models import LogEntry, CHANGE
from django.contrib.contenttypes.models import ContentType
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
def make_published(self, request, queryset):
"""Publish articles with audit trail"""
updated_articles = []
for article in queryset:
if article.status != 'published':
old_status = article.status
article.status = 'published'
article.published_at = timezone.now()
article.save()
updated_articles.append(article)
# Log the action
LogEntry.objects.log_action(
user_id=request.user.id,
content_type_id=ContentType.objects.get_for_model(article).pk,
object_id=article.pk,
object_repr=str(article),
action_flag=CHANGE,
change_message=f'Status changed from "{old_status}" to "published" via bulk action'
)
self.message_user(
request,
f'{len(updated_articles)} articles were published.',
messages.SUCCESS
)
make_published.short_description = "Publish selected articles"
actions = ['make_published']
<!-- templates/admin/assign_category.html -->
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static admin_list %}
{% block title %}Assign Category{% 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>
› Assign Category
</div>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<div class="module">
<h2>Assign Category to Selected Articles</h2>
<p>You have selected {{ queryset.count }} article{{ queryset.count|pluralize }} for category assignment:</p>
<ul>
{% for obj in queryset %}
<li>{{ obj }}</li>
{% endfor %}
</ul>
<fieldset class="module aligned">
<div class="form-row">
{{ form.category.label_tag }}
{{ form.category }}
{% if form.category.help_text %}
<p class="help">{{ form.category.help_text }}</p>
{% endif %}
</div>
<div class="form-row">
{{ form.overwrite_existing }}
{{ form.overwrite_existing.label_tag }}
{% if form.overwrite_existing.help_text %}
<p class="help">{{ form.overwrite_existing.help_text }}</p>
{% endif %}
</div>
</fieldset>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}" />
{% endfor %}
<input type="hidden" name="action" value="assign_category" />
<div class="submit-row">
<input type="submit" name="apply" value="Assign Category" class="default" />
<a href="{% url 'admin:myapp_article_changelist' %}" class="button cancel-link">Cancel</a>
</div>
</div>
</form>
{% endblock %}
from django.test import TestCase, RequestFactory
from django.contrib.auth.models import User
from django.contrib.admin.sites import AdminSite
from .admin import ArticleAdmin
from .models import Article
class AdminActionTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_superuser(
username='admin',
email='admin@example.com',
password='testpass123'
)
self.site = AdminSite()
self.admin = ArticleAdmin(Article, self.site)
# Create test articles
self.articles = [
Article.objects.create(
title=f'Article {i}',
content='Test content',
status='draft',
author=self.user
)
for i in range(3)
]
def test_make_published_action(self):
"""Test the make_published action"""
request = self.factory.post('/admin/')
request.user = self.user
queryset = Article.objects.filter(status='draft')
# Execute action
self.admin.make_published(request, queryset)
# Check results
published_count = Article.objects.filter(status='published').count()
self.assertEqual(published_count, 3)
def test_export_csv_action(self):
"""Test CSV export action"""
request = self.factory.post('/admin/')
request.user = self.user
queryset = Article.objects.all()
# Execute action
response = self.admin.export_as_csv(request, queryset)
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/csv')
self.assertIn('attachment', response['Content-Disposition'])
queryset.update() for simple bulk updatesNow that you've mastered admin actions, let's explore security best practices to ensure your Django admin interface is properly protected in production environments.
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.
Admin Security Best Practices
Securing the Django admin interface is crucial for protecting your application and data. This chapter covers comprehensive security measures, from basic configurations to advanced protection strategies.