Configuration
Detail actions are configured using theactions_detail attribute in your ModelAdmin class:
admin.py
from django.contrib import admin
from unfold.admin import ModelAdmin
from unfold.decorators import action
from .models import Article
@admin.register(Article)
class ArticleAdmin(ModelAdmin):
actions_detail = [
"preview_article",
"duplicate_article",
"view_analytics",
]
Basic Detail Action
A detail action receives bothrequest and object_id parameters:
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse_lazy
from unfold.decorators import action
@action(description="Preview Article")
def preview_article(self, request, object_id):
# Get the object
article = self.get_object(request, object_id)
# Perform action logic
preview_url = article.get_preview_url()
messages.info(request, f"Opening preview for: {article.title}")
return redirect(preview_url)
Use
self.get_object(request, object_id) to safely retrieve the object instance.Styled Actions with Icons
Enhance detail actions with visual styling:from unfold.decorators import action
from unfold.enums import ActionVariant
@action(
description="Preview",
icon="eye",
variant=ActionVariant.INFO
)
def preview_article(self, request, object_id):
article = self.get_object(request, object_id)
return redirect(article.get_absolute_url())
@action(
description="Duplicate",
icon="copy",
variant=ActionVariant.PRIMARY
)
def duplicate_article(self, request, object_id):
original = self.get_object(request, object_id)
# Create duplicate
duplicate = self.model.objects.create(
title=f"{original.title} (Copy)",
content=original.content,
author=request.user,
)
messages.success(request, f"Article duplicated: {duplicate.title}")
return redirect(reverse_lazy("admin:myapp_article_change", args=[duplicate.pk]))
@action(
description="Delete",
icon="trash",
variant=ActionVariant.DANGER
)
def delete_article(self, request, object_id):
article = self.get_object(request, object_id)
title = article.title
article.delete()
messages.warning(request, f"Deleted article: {title}")
return redirect(reverse_lazy("admin:myapp_article_changelist"))
Dropdown Groups
Organize related actions into dropdown menus:@admin.register(Article)
class ArticleAdmin(ModelAdmin):
actions_detail = [
"preview",
"duplicate",
{
"title": "Publishing",
"icon": "upload",
"variant": ActionVariant.SUCCESS,
"items": [
"publish_now",
"schedule_publish",
"unpublish",
],
},
{
"title": "Export",
"icon": "download",
"items": [
"export_pdf",
"export_docx",
"export_markdown",
],
},
]
@action(description="Preview", icon="eye")
def preview(self, request, object_id):
pass
@action(description="Duplicate", icon="copy")
def duplicate(self, request, object_id):
pass
@action(description="Publish Now", variant=ActionVariant.SUCCESS)
def publish_now(self, request, object_id):
article = self.get_object(request, object_id)
article.status = "published"
article.save()
messages.success(request, "Article published")
return redirect(reverse_lazy("admin:myapp_article_change", args=[object_id]))
@action(description="Schedule Publishing")
def schedule_publish(self, request, object_id):
pass
@action(description="Unpublish", variant=ActionVariant.WARNING)
def unpublish(self, request, object_id):
pass
Group actions by functionality (e.g., Publishing, Export, Workflow) to keep the interface organized.
Permission-Protected Actions
Restrict actions based on user permissions:@action(
description="Publish",
permissions=["myapp.publish_article"],
icon="check",
variant=ActionVariant.SUCCESS
)
def publish_article(self, request, object_id):
# Only users with publish_article permission can execute this
article = self.get_object(request, object_id)
article.publish()
messages.success(request, "Article published")
return redirect(reverse_lazy("admin:myapp_article_change", args=[object_id]))
@action(
description="Approve",
permissions=["approve"],
icon="thumbs-up",
variant=ActionVariant.PRIMARY
)
def approve_article(self, request, object_id):
# Uses custom permission method
article = self.get_object(request, object_id)
article.approved_by = request.user
article.approved_at = timezone.now()
article.save()
return redirect(reverse_lazy("admin:myapp_article_change", args=[object_id]))
def has_approve_permission(self, request, object_id):
# Custom permission logic
if not request.user.is_staff:
return False
# Only allow approving others' articles
article = self.get_object(request, object_id)
return article.author != request.user
Detail action permission methods receive both
request and object_id parameters, allowing for object-level permission checks.External Preview Example
Open previews in new tabs:@action(
description="Preview on Site",
icon="external-link",
variant=ActionVariant.INFO,
attrs={"target": "_blank", "rel": "noopener noreferrer"}
)
def preview_on_site(self, request, object_id):
article = self.get_object(request, object_id)
return redirect(article.get_absolute_url())
@action(
description="View Analytics",
icon="chart-line",
attrs={"target": "_blank"},
url_path="analytics"
)
def view_analytics(self, request, object_id):
article = self.get_object(request, object_id)
analytics_url = f"https://analytics.example.com/article/{article.id}"
return redirect(analytics_url)
Workflow Actions Example
Implement a complete approval workflow:from django.utils import timezone
from unfold.decorators import action
from unfold.enums import ActionVariant
@admin.register(Article)
class ArticleAdmin(ModelAdmin):
actions_detail = [
{
"title": "Workflow",
"icon": "diagram-project",
"items": [
"submit_for_review",
"approve",
"reject",
"publish",
"archive",
],
},
]
@action(
description="Submit for Review",
permissions=["submit_review"],
icon="paper-plane",
variant=ActionVariant.INFO
)
def submit_for_review(self, request, object_id):
article = self.get_object(request, object_id)
article.status = "in_review"
article.submitted_at = timezone.now()
article.save()
# Notify reviewers
self._notify_reviewers(article)
messages.success(request, "Article submitted for review")
return redirect(reverse_lazy("admin:myapp_article_change", args=[object_id]))
def has_submit_review_permission(self, request, object_id):
article = self.get_object(request, object_id)
return article.author == request.user and article.status == "draft"
@action(
description="Approve",
permissions=["approve"],
icon="check-circle",
variant=ActionVariant.SUCCESS
)
def approve(self, request, object_id):
article = self.get_object(request, object_id)
article.status = "approved"
article.approved_by = request.user
article.approved_at = timezone.now()
article.save()
messages.success(request, f"Article approved: {article.title}")
return redirect(reverse_lazy("admin:myapp_article_change", args=[object_id]))
def has_approve_permission(self, request, object_id):
article = self.get_object(request, object_id)
return (
request.user.groups.filter(name="Editors").exists()
and article.status == "in_review"
)
@action(
description="Reject",
permissions=["approve"],
icon="times-circle",
variant=ActionVariant.DANGER
)
def reject(self, request, object_id):
article = self.get_object(request, object_id)
article.status = "rejected"
article.rejected_by = request.user
article.rejected_at = timezone.now()
article.save()
messages.warning(request, "Article rejected")
return redirect(reverse_lazy("admin:myapp_article_change", args=[object_id]))
def has_reject_permission(self, request, object_id):
return self.has_approve_permission(request, object_id)
File Export Example
Generate and download files for specific objects:import io
from django.http import HttpResponse
from reportlab.pdfgen import canvas
from unfold.decorators import action
@action(
description="Export as PDF",
icon="file-pdf",
variant=ActionVariant.INFO
)
def export_pdf(self, request, object_id):
article = self.get_object(request, object_id)
# Create PDF
buffer = io.BytesIO()
p = canvas.Canvas(buffer)
# Add content
p.drawString(100, 750, f"Title: {article.title}")
p.drawString(100, 730, f"Author: {article.author.get_full_name()}")
p.drawString(100, 700, "Content:")
# Wrap text content
text = article.content
y = 680
for line in text.split('\n'):
p.drawString(100, y, line[:80])
y -= 20
p.showPage()
p.save()
# Return response
buffer.seek(0)
response = HttpResponse(buffer, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{article.slug}.pdf"'
return response
Best Practices
Stay on the same page
Stay on the same page
For most detail actions, redirect back to the change form to maintain context:
return redirect(reverse_lazy("admin:myapp_article_change", args=[object_id]))
Use object-level permissions
Use object-level permissions
Take advantage of the
object_id parameter in permission methods:def has_action_permission(self, request, object_id):
obj = self.get_object(request, object_id)
return obj.author == request.user
Provide clear feedback
Provide clear feedback
Always inform users of the action result:
messages.success(request, f"Article '{article.title}' was published")
Handle errors gracefully
Handle errors gracefully
Wrap actions in try-except blocks for better error handling:
try:
article.publish()
messages.success(request, "Published successfully")
except ValidationError as e:
messages.error(request, f"Publishing failed: {e}")
Hiding Default Buttons
Replace Django’s default Save/Delete buttons with custom actions:@admin.register(Article)
class ArticleAdmin(ModelAdmin):
actions_detail_hide_default = True
actions_detail = [
"save_article",
"delete_article",
]
When hiding default buttons, ensure you provide equivalent functionality through your custom actions.
Related Actions
Submit Line Actions
Add custom buttons to the form submit line
List Actions
Create actions for changelist views