Action System Architecture¶
This document provides a deep dive into django-admin-deux's action-centric architecture, explaining why actions are first-class citizens and how they enable superior composition and extensibility.
Why Action-Centric?¶
Traditional admin interfaces (including Django's admin) are view-centric: you configure views, and actions are secondary features attached to those views. django-admin-deux inverts this relationship, making actions primary and views become generated artifacts.
The Problem with View-Centric Design¶
# Traditional approach (Django admin)
class ProductAdmin(admin.ModelAdmin):
list_display = ['name', 'price']
def export_csv(self, request, queryset):
# Export logic here
pass
export_csv.short_description = "Export to CSV"
actions = [export_csv]
Issues: 1. Actions are functions with magical attributes 2. No context beyond function signature 3. Difficult to compose or extend actions 4. Actions can't carry their own configuration 5. No clear separation between "main views" and "operations"
The Action-Centric Solution¶
# django-admin-deux approach
class ProductAdmin(ModelAdmin):
list_display = ['name', 'price']
general_actions = [ListAction, AddAction]
bulk_actions = [ExportCSVAction, DeleteBulkAction]
record_actions = [EditAction, ViewAction]
Benefits: 1. Actions are first-class objects with full context 2. Clear separation: general (views) vs bulk vs record 3. Actions compose through mixins 4. Actions carry configuration and behavior 5. Actions generate their own views
Action Hierarchy¶
BaseAction (abstract)
├─ Context: model, model_admin, admin_site
├─ Display: label, icon, css_class
├─ Behavior: confirmation_required, http_method
├─ Permissions: permission_class, django_permission_name
└─ Methods: get_url_pattern(), check_permission()
Action Type Mixins (what it operates on)
├─ GeneralActionMixin → No selection needed
├─ BulkActionMixin → Operates on selected records
└─ RecordActionMixin → Operates on single record
View-Type Mixins (what view it generates)
├─ ListActionMixin → Generates ListView
├─ CreateViewActionMixin → Generates CreateView
├─ UpdateViewActionMixin → Generates UpdateView
├─ FormViewActionMixin → Generates FormView
└─ DetailViewActionMixin → Generates DetailView (read-only)
View-Type Mixins (how it generates redirects)
└─ RedirectViewActionMixin → Generates RedirectView (instant redirect)
Behavior Mixins (how it behaves)
├─ FormActionMixin → Displays embedded form (modal)
├─ ConfirmationActionMixin → Requires confirmation
└─ DownloadActionMixin → Returns file download
Three Action Types¶
1. List Actions (General Actions)¶
Purpose: Main entry points that don't require record selection.
Characteristics:
- Mix in GeneralActionMixin
- Usually also mix in a view-type mixin (ListView, CreateView, etc.)
- Displayed as toolbar buttons
- No record selection required
Examples:
# ListView - the main list view
class ListAction(ListActionMixin, GeneralActionMixin, BaseAction):
label = "View All"
icon = "list"
# ListActionMixin provides base_class = ListView
# Add - create new record
class AddAction(CreateViewActionMixin, GeneralActionMixin, BaseAction):
label = "Add"
icon = "plus"
# CreateViewActionMixin provides base_class = CreateView
URL Patterns:
/admin/products/ → ListAction
/admin/products/add/ → AddAction
/admin/products/import/ → ImportAction (custom)
2. Bulk Actions¶
Purpose: Operations on multiple selected records.
Characteristics:
- Mix in BulkActionMixin
- Receive a queryset of selected objects
- Displayed in ListView action dropdown
- Require checkbox selection
Examples:
# Delete multiple records
class DeleteBulkAction(ConfirmationActionMixin, BulkActionMixin, BaseAction):
label = "Delete Selected"
icon = "trash"
confirmation_required = True
def post(self, request, *args, **kwargs):
queryset = self.get_queryset()
count = queryset.count()
queryset.delete()
messages.success(request, f"Deleted {count} items")
return redirect(self.model_admin.list_url_name)
# Export selected to CSV
class ExportCSVAction(DownloadActionMixin, BulkActionMixin, BaseAction):
label = "Export to CSV"
icon = "download"
def get_download_response(self, request, queryset):
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="export.csv"'
writer = csv.writer(response)
# Write data from queryset
return response
URL Patterns:
/admin/products/delete_bulk/ → DeleteBulkAction
/admin/products/export_csv/ → ExportCSVAction (custom bulk action)
3. Record Actions¶
Purpose: Operations on a single record.
Characteristics:
- Mix in RecordActionMixin
- Receive a single object
- Displayed in ListView rows and detail pages
- Operate on one record at a time
Examples:
# Edit existing record
class EditAction(UpdateViewActionMixin, RecordActionMixin, BaseAction):
label = "Edit"
icon = "pencil"
# UpdateViewActionMixin provides base_class = UpdateView
# View record details (read-only)
class ViewAction(DetailViewActionMixin, RecordActionMixin, BaseAction):
label = "View"
icon = "eye"
# DetailViewActionMixin provides base_class = DetailView
# Custom record action with form
class ChangeStatusAction(FormActionMixin, RecordActionMixin, BaseAction):
label = "Change Status"
icon = "exchange"
form_class = StatusChangeForm
def form_valid(self, request, form, obj):
obj.status = form.cleaned_data['status']
obj.save()
return redirect(self.model_admin.list_url_name)
URL Patterns:
/admin/products/123/edit/ → EditAction
/admin/products/123/view/ → ViewAction
/admin/products/123/change_status/ → ChangeStatusAction (custom record action)
Action Composition Patterns¶
Pattern 1: View-Generating Actions¶
Most common pattern: action generates a full Django CBV.
class AddAction(
CreateViewActionMixin, # Provides base_class = CreateView
GeneralActionMixin, # No selection needed
BaseAction # Core functionality
):
label = "Add"
icon = "plus"
# ViewFactory will:
# 1. Get base_class = CreateView (from mixin)
# 2. Add form processing mixins from plugins
# 3. Add success_url, form_class from ModelAdmin
# 4. Generate: type('ProductAddActionView', (CreateView,), {...})
Flow:
Pattern 2: Form-Embedded Actions¶
Action displays a small form (typically in modal) and handles submission directly.
class SendEmailAction(
FormActionMixin, # Provides form handling
BulkActionMixin, # Operates on selection
BaseAction
):
label = "Send Email"
form_class = EmailForm
def form_valid(self, request, form, queryset):
# Send emails to selected users
for obj in queryset:
send_mail(...)
messages.success(request, "Emails sent!")
return redirect(...)
Flow:
Pattern 3: Redirect Actions¶
Action redirects immediately to another URL without displaying content.
# Static redirect URL
class GoToDashboardAction(
RedirectViewActionMixin, # Generates RedirectView
GeneralActionMixin,
BaseAction
):
label = "Dashboard"
redirect_url = '/dashboard/' # Static URL
# Dynamic redirect URL
class ExternalLinkAction(
RedirectViewActionMixin, # Generates RedirectView
RecordActionMixin,
BaseAction
):
label = "View External"
def get_redirect_url(self, *args, **kwargs):
# self is the VIEW instance (not the action)
# Access view attributes: self.kwargs, self.request, etc.
pk = self.kwargs.get('pk')
return f'https://example.com/products/{pk}'
How it works:
- RedirectViewActionMixin sets base class to Django's RedirectView
- Core plugin adds RedirectViewMixin which implements get_redirect_url() dispatch:
1. Check action's redirect_url attribute (static URL)
2. Delegate to action's get_redirect_url() method (dynamic URL)
3. Fall back to Django's RedirectView default
- The action's get_redirect_url() method is bound to the view instance via __func__
Flow:
Action → Action.dispatch() → Action.get() → RedirectViewMixin.get_redirect_url() → Immediate redirect
Pattern 4: Direct Execution Actions¶
Action executes immediately without view generation.
class DuplicateAction(
RecordActionMixin,
BaseAction
):
label = "Duplicate"
http_method = 'POST'
def post(self, request, *args, **kwargs):
# Get and clone the object
obj = self.get_object()
new_obj = obj.duplicate()
messages.success(request, f"Created duplicate: {new_obj}")
return redirect(self.model_admin.list_url_name)
Flow:
Action Context¶
Every action carries rich context through initialization:
def __init__(self, model, model_admin, admin_site):
self.model = model # Django model class
self.model_admin = model_admin # ModelAdmin instance
self.admin_site = admin_site # AdminSite instance
This context enables:
class ExportAction(BulkActionMixin, BaseAction):
def dispatch(self, request, *args, **kwargs):
# Access model metadata
opts = self.model._meta
filename = f"{opts.model_name}_export.csv"
# Access admin configuration
columns = self.model_admin.list_display
# Access site configuration
site_name = self.admin_site.name
# Get queryset and generate export
queryset = self.get_queryset()
return generate_csv(queryset, columns, filename)
Action Discovery and URL Generation¶
Registration Flow¶
# 1. Developer registers ModelAdmin
@register(Product)
class ProductAdmin(ModelAdmin):
general_actions = [ListAction, AddAction]
record_actions = [EditAction, DeleteAction]
# 2. ModelAdmin.__init__() instantiates actions
def _initialize_actions(self):
# Get plugin defaults
plugin_defaults = pm.hook.djadmin_get_default_general_actions()
# Use user overrides or defaults
action_classes = self.general_actions or plugin_defaults
# Instantiate with context
self.general_actions = [
ActionClass(self.model, self, self.admin_site)
for ActionClass in action_classes
]
# 3. AdminSite generates URLs
def get_urls(self):
for model, model_admin in self._registry.items():
# Generate URLs for each action
for action in model_admin.general_actions:
path(action.get_url_pattern(),
action.get_view_class().as_view(),
name=action.url_name)
URL Pattern Generation¶
Actions control their URL patterns:
class BaseAction:
@property
def action_name(self) -> str:
"""Generate action name from class name (e.g., EditAction -> 'edit')"""
from django.utils.text import slugify
name = slugify(self.__class__.__name__)
name = name.replace('-', '_')
return name.replace('_action', '') # Remove '_action' suffix
def get_url_pattern(self) -> str:
"""Default URL pattern uses action_name property"""
opts = self.model._meta
# For RecordActionMixin: {app}/{model}/<pk>/{action}/
# For GeneralActionMixin: {app}/{model}/{action}/
# For BulkActionMixin: {app}/{model}/{action_bulk}/
return f'{opts.app_label}/{opts.model_name}/{self.action_name}/'
Examples of generated URL patterns:
# ListAction generates:
# {app}/{model}/list/ → /products/list/
# AddAction generates:
# {app}/{model}/add/ → /products/add/
# EditAction (with RecordActionMixin) generates:
# {app}/{model}/<pk>/edit/ → /products/123/edit/
# DeleteBulkAction generates:
# {app}/{model}/delete_bulk/ → /products/delete_bulk/
Action Permissions (Milestone 5)¶
Actions use the declarative permission system introduced in Milestone 5:
permission_class - Declarative Permissions¶
Actions declare permissions using the permission_class attribute:
from djadmin.plugins.permissions import IsStaff, HasDjangoPermission
class DeleteBulkAction(BulkActionMixin, BaseAction):
label = "Delete Selected"
# Declarative permission: must be staff AND have delete permission
permission_class = IsStaff() & HasDjangoPermission()
django_permission_name = 'delete'
Available permission classes:
from djadmin.plugins.permissions import (
AllowAny, # No restrictions
IsAuthenticated, # Requires authenticated user
IsStaff, # Requires staff status
IsSuperuser, # Requires superuser status
HasDjangoPermission, # Checks Django model permissions
)
Permission Composition¶
Combine permissions using operators:
# AND: Both must pass
permission_class = IsStaff() & HasDjangoPermission()
# OR: Either can pass
permission_class = IsStaff() | IsSuperuser()
# NOT: Invert result
permission_class = IsAuthenticated() & ~HasDjangoPermission(perm='change')
check_permission() - Runtime Checks¶
Actions check permissions at runtime using check_permission():
def check_permission(self, request: HttpRequest) -> bool:
"""Check if user has permission to execute this action."""
# Creates a view instance with PermissionMixin
# Calls view.test_func() which evaluates permission_class
# Returns True if permission granted, False otherwise
This method is called automatically by:
- ModelAdmin.filter_actions() - Filters action lists based on permissions
- ListView.get_context_data() - Only shows actions user can access
- Dashboard views - Hides models/apps with no accessible actions
Benefits of Action-Centric Design¶
1. Clear Separation of Concerns¶
# Actions are cleanly separated by purpose
general_actions = [ListAction, AddAction, ImportAction]
bulk_actions = [DeleteBulkAction, ExportAction]
record_actions = [EditAction, ViewAction, DuplicateAction]
2. Easy Composition¶
# Combine mixins to create new actions
class ExportPDFAction(
DownloadActionMixin, # Returns file
BulkActionMixin, # Operates on selection
BaseAction # Core functionality
):
label = "Export PDF"
def get_download_response(self, request, queryset):
return generate_pdf(queryset)
3. Plugin-Friendly¶
Plugins can provide actions without modifying core:
4. Testability¶
Actions are self-contained units:
def test_delete_bulk_action():
action = DeleteBulkAction(Product, product_admin, site)
view = action.get_view_class().as_view()
response = view(request)
assert Product.objects.count() == 0
5. Reusability¶
Actions work across models:
# Same action works for any model
class ExportCSVAction(DownloadActionMixin, BulkActionMixin, BaseAction):
# Generic implementation using self.model
pass
# Use for different models
class ProductAdmin(ModelAdmin):
bulk_actions = [ExportCSVAction]
class OrderAdmin(ModelAdmin):
bulk_actions = [ExportCSVAction] # Same action
Implementation Reference¶
Source Code:
- Base classes: djadmin/actions/base.py
- ListView action: djadmin/actions/list_view.py
- View-type mixins: djadmin/actions/view_mixins.py
- Core actions: djadmin/plugins/core/actions.py
Related Documentation: - ViewFactory Internals - How actions become views - Plugin System - How plugins extend actions - Design Decisions - Why action-centric