Skip to content

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:

Action → ViewFactory → Generated CBV → Standard Django request handling

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:

Action → Form display → Action.form_valid() → Execute logic → Redirect

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 → Action.post() → Business logic → Redirect

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:

@hookimpl
def djadmin_get_default_general_actions():
    return [SearchAction, AdvancedFilterAction]

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