Skip to content

Plugin System Architecture

This document explains django-admin-deux's plugin system, built on djp (Django plugin framework) and pluggy (Python plugin system), including hook discovery, execution, and plugin development patterns.

Why Plugins?

django-admin-deux treats extensibility as a first-class concern. Rather than requiring users to fork or monkey-patch, the system is designed to be extended through well-defined plugin hooks.

Goals: 1. Core functionality as plugins: Even built-in features (CRUD, theme) are plugins 2. Third-party extensions: Anyone can add features (search, export, permissions) 3. No code modification: Plugins extend without touching core code 4. Clean boundaries: Clear contracts between core and plugins 5. Composition: Multiple plugins work together seamlessly

Technology Stack

djp (Django Plugin Manager)

What it is: A Django-specific wrapper around pluggy that integrates with Django's app system.

Key features: - Auto-discovers plugins from INSTALLED_APPS - Looks for djadmin_hooks.py in each app - Provides Django-friendly API - Handles plugin initialization

Why we use it: - Built for Django patterns - Simpler than raw pluggy for Django projects - Well-maintained and documented - Used by Datasette and other production systems

pluggy (Python Plugin System)

What it is: A mature plugin framework used by pytest and other major projects.

Key features: - Hook specifications (contracts) - Hook implementations (plugins) - Multiple plugins per hook - Ordered execution - First-result or all-results patterns

Why we use it: - Battle-tested (pytest uses it) - Flexible hook patterns - Type-safe specifications - Excellent documentation

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    Django Project                            │
│  INSTALLED_APPS = [                                         │
│      'djadmin',                                             │
│      'djadmin.plugins.core',                                │
│      'djadmin.plugins.theme',                               │
│      'myapp',                     ← Can provide hooks       │
│      'third_party_search',        ← Can provide hooks       │
│  ]                                                          │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────────┐
│                 djp PluginManager                           │
│  • Discovers djadmin_hooks.py in each app                   │
│  • Loads hook implementations                               │
│  • Provides pm.hook.* API                                   │
└─────────────────────┬───────────────────────────────────────┘
        ┌─────────────┼─────────────┐
        │             │             │
┌───────▼──────┐ ┌───▼──────┐ ┌───▼──────────┐
│ Core Plugin  │ │  Theme   │ │ Third-Party  │
│              │ │  Plugin  │ │   Plugin     │
├──────────────┤ ├──────────┤ ├──────────────┤
│ • CRUD ops   │ │ • UI/UX  │ │ • Search     │
│ • Actions    │ │ • Assets │ │ • Export     │
│ • Views      │ │ • Style  │ │ • Permissions│
└──────────────┘ └──────────┘ └──────────────┘

Plugin Discovery

Discovery Process

  1. Django app registration: App is in INSTALLED_APPS
  2. djp scans: Looks for djadmin_hooks.py in each app
  3. Hook registration: Imports module and registers @hookimpl functions
  4. Ready to use: Hooks are available via pm.hook.*

File Structure

myapp/
├─ __init__.py
├─ models.py
├─ views.py
└─ djadmin_hooks.py     ← Plugin hooks go here (exact name required)

Example Plugin Module

# myapp/djadmin_hooks.py
from djadmin.plugins import hookimpl

@hookimpl
def djadmin_provides_features():
    """Advertise features this plugin provides"""
    return ['search', 'advanced_filter']

@hookimpl
def djadmin_get_action_view_mixins(action):
    """Add mixins to actions"""
    from djadmin.actions.list_view import ListAction
    from myapp.mixins import SearchMixin

    return {
        ListAction: [SearchMixin]
    }

Hook Specifications

Hook specifications define the contract between core and plugins. They specify: - Hook name - Parameters - Return type/structure - Execution pattern (first result, all results, etc.)

Location: djadmin/plugins/__init__.py

Hook Execution Patterns

Pattern 1: All Results (List Collection)

Most common pattern. All plugins contribute, results are collected in a list.

@hookspec
def djadmin_get_default_general_actions():
    """Return list of default general actions"""
    pass

# Execution
results = pm.hook.djadmin_get_default_general_actions()
# results = [
#     [ListAction, AddAction],        # Core plugin
#     [SearchAction, FilterAction],       # Search plugin
# ]

# Usage: Flatten and use all
all_actions = []
for action_list in results:
    if action_list:
        all_actions.extend(action_list)

Pattern 2: All Results (Registry Collection)

Plugins return dictionaries, all are collected and processed.

@hookspec
def djadmin_get_action_view_mixins(action):
    """Return dict mapping action classes to mixin lists"""
    pass

# Execution
results = pm.hook.djadmin_get_action_view_mixins(action=my_action)
# results = [
#     {ListAction: [Mixin1, Mixin2]},     # Core plugin
#     {ListAction: [Mixin3]},             # Theme plugin
#     {BaseAction: [GlobalMixin]},            # Utility plugin
# ]

# Usage: Check isinstance() for each registry
mixins = []
for registry in results:
    if registry:
        for action_class, mixin_list in registry.items():
            if isinstance(action, action_class):
                mixins.extend(mixin_list)

Pattern 3: First Non-None Result

Less common. First plugin to return non-None wins.

Note: This pattern was used by the now-removed djadmin_action_get_success_url hook. Actions now handle redirect URLs via RedirectActionMixin with view-level get_redirect_url() method.

Core Hook Categories

1. Feature Advertising

Purpose: Plugins declare what features they provide.

@hookspec
def djadmin_provides_features():
    """Return list of feature names this plugin provides"""
    pass

Example:

@hookimpl
def djadmin_provides_features():
    return ['crud', 'theme']  # Core plugin
    return ['search', 'filter']  # Search plugin

Usage: At startup, validate ModelAdmin requests against available features.

2. Action Hooks

Purpose: Plugins provide default actions.

@hookspec
def djadmin_get_default_general_actions():
    """Return list of default general actions"""
    pass

@hookspec
def djadmin_get_default_bulk_actions():
    """Return list of default bulk actions"""
    pass

@hookspec
def djadmin_get_default_record_actions():
    """Return list of default record actions"""
    pass

Example:

@hookimpl
def djadmin_get_default_general_actions():
    from djadmin.plugins.core.actions import ListAction, AddAction
    return [ListAction, AddAction]

@hookimpl
def djadmin_get_default_record_actions():
    from djadmin.plugins.core.actions import EditAction
    return [EditAction]

3. View Factory Hooks

Purpose: Plugins customize view generation.

@hookspec
def djadmin_get_action_view_mixins(action):
    """Return dict mapping action classes to mixin lists"""
    pass

@hookspec
def djadmin_get_action_view_base_class(action):
    """Return dict mapping action classes to base class overrides"""
    pass

@hookspec
def djadmin_get_action_view_attributes(action):
    """Return dict mapping action classes to attribute dicts"""
    pass

@hookspec
def djadmin_get_action_view_attribute_bindings(action):
    """Return dict mapping action classes to attribute name lists"""
    pass

Example:

@hookimpl
def djadmin_get_action_view_mixins(action):
    from djadmin.actions.view_mixins import ListActionMixin
    from djadmin.plugins.core.mixins import BreadcrumbMixin

    return {
        ListActionMixin: [BreadcrumbMixin, ActionContextMixin]
    }

@hookimpl
def djadmin_get_action_view_attributes(action):
    from djadmin.actions.view_mixins import ListActionMixin

    return {
        ListActionMixin: {
            'paginate_by': action.model_admin.paginate_by,
            'ordering': action.model_admin.ordering or ['-pk'],
        }
    }

4. Data Modification Hooks

Purpose: Plugins modify querysets and context.

@hookspec
def djadmin_modify_queryset(queryset, request, view):
    """Modify queryset before rendering"""
    pass

@hookspec
def djadmin_add_context_data(context, request, view):
    """Add extra context data to views"""
    pass

Example:

@hookimpl
def djadmin_modify_queryset(queryset, request, view):
    # Apply row-level permissions
    if not request.user.is_superuser:
        queryset = queryset.filter(owner=request.user)
    return queryset

@hookimpl
def djadmin_add_context_data(context, request, view):
    # Add navigation items
    context['nav_items'] = get_navigation(request)
    return context

5. Action Lifecycle

Note: Action lifecycle hooks (djadmin_action_get_success_url) have been removed. Actions now handle redirect URLs via RedirectActionMixin with view-level get_redirect_url() method.

For executing code before/after action execution, use view mixins that override dispatch() or form_valid() methods.

Built-in Plugins

Core Plugin

Location: djadmin/plugins/core/

Provides: - Feature: crud - Default actions: ListView, Add, Edit, Delete - Base view mixins: Breadcrumbs, context - Attribute bindings: get_queryset, get_template_names

Implementation: djadmin/plugins/core/djadmin_hooks.py

@hookimpl
def djadmin_provides_features():
    return ['crud']

@hookimpl
def djadmin_get_default_general_actions():
    return [ListAction, AddAction]

Theme Plugin

Location: djadmin/plugins/theme/

Provides: - Feature: theme - UI mixins: Pagination display, sorting UI - Assets: CSS and JavaScript files - Templates: Base layout and components

Implementation: djadmin/plugins/theme/djadmin_hooks.py

@hookimpl
def djadmin_provides_features():
    return ['theme']

@hookimpl
def djadmin_get_action_view_assets(action):
    from djadmin import JSAsset, CSSAsset
    from djadmin.actions.view_mixins import ListActionMixin

    return {
        ListActionMixin: {
            'css': [CSSAsset(href='djadmin/theme/list.css')],
            'js': [JSAsset(src='djadmin/theme/list.js', defer=True)],
        }
    }

Plugin Development

Minimal Plugin

# myapp/djadmin_hooks.py
from djadmin.plugins import hookimpl

@hookimpl
def djadmin_provides_features():
    """Advertise features"""
    return ['myfeature']

@hookimpl
def djadmin_get_default_general_actions():
    """Provide default actions"""
    from myapp.actions import MyAction
    return [MyAction]

Activation: Add 'myapp' to INSTALLED_APPS

Search Plugin Example

# search_plugin/djadmin_hooks.py
from djadmin.plugins import hookimpl

@hookimpl
def djadmin_provides_features():
    """Provide search feature"""
    return ['search']

@hookimpl
def djadmin_get_action_view_mixins(action):
    """Add search mixin to ListView"""
    from djadmin.actions.list_view import ListAction
    from search_plugin.mixins import SearchMixin

    return {
        ListAction: [SearchMixin]
    }

@hookimpl
def djadmin_get_action_view_attributes(action):
    """Add search configuration"""
    from djadmin.actions.view_mixins import ListActionMixin

    # Only if model_admin has search_fields
    if hasattr(action.model_admin, 'search_fields'):
        if action.model_admin.search_fields:
            return {
                ListActionMixin: {
                    'search_fields': action.model_admin.search_fields,
                    'search_param': 'q',
                }
            }
    return {}

@hookimpl
def djadmin_modify_queryset(queryset, request, view):
    """Apply search filter"""
    if hasattr(view, 'search_fields') and 'q' in request.GET:
        query = request.GET['q']
        # Apply search across fields
        return apply_search(queryset, query, view.search_fields)
    return queryset

Usage:

# settings.py
INSTALLED_APPS = [
    'djadmin',
    'search_plugin',  # Enable search
    'myapp',
]

# myapp/djadmin.py
class ProductAdmin(ModelAdmin):
    search_fields = ['name', 'description']  # Triggers 'search' feature

Export Plugin Example

# export_plugin/djadmin_hooks.py
from djadmin.plugins import hookimpl

@hookimpl
def djadmin_provides_features():
    return ['export']

@hookimpl
def djadmin_get_default_bulk_actions():
    """Add export actions"""
    from export_plugin.actions import ExportCSVAction, ExportPDFAction
    return [ExportCSVAction, ExportPDFAction]

@hookimpl
def djadmin_add_context_data(context, request, view):
    """Add export formats to context"""
    context['export_formats'] = ['csv', 'json', 'pdf']
    return context

Plugin Ordering

Hook Execution Order

By default, hooks execute in plugin registration order (INSTALLED_APPS order).

INSTALLED_APPS = [
    'djadmin.plugins.core',      # 1st
    'djadmin.plugins.theme',     # 2nd
    'search_plugin',             # 3rd
    'myapp',                     # 4th
]

Result: Core runs first, myapp runs last.

Controlling Order

For precise control, use tryfirst=True or trylast=True:

@hookimpl(tryfirst=True)
def djadmin_modify_queryset(queryset, request, view):
    """Run before other plugins"""
    return queryset.select_related('category')

@hookimpl(trylast=True)
def djadmin_modify_queryset(queryset, request, view):
    """Run after other plugins"""
    return queryset.filter(is_public=True)

Order Matters For

  1. Context modification: Later plugins see earlier changes
  2. Queryset modification: Later filters apply to already-filtered queryset
  3. Mixin order: Earlier mixins override later mixins (MRO)
  4. Asset loading: Assets with blocking=True load in <head> before rendering, others load at end of <body>. Within each section, later assets load after earlier assets (CSS/JS order)

Plugin Testing

Test Plugin Hooks

# tests/test_myplugin.py
from djadmin.plugins import pm

def test_provides_features():
    """Test feature advertising"""
    results = pm.hook.djadmin_provides_features()
    features = [f for result in results for f in (result or [])]
    assert 'myfeature' in features

def test_provides_actions():
    """Test action provision"""
    results = pm.hook.djadmin_get_default_general_actions()
    actions = [a for result in results for a in (result or [])]
    assert any(isinstance(a, MyAction) for a in actions)

Test Plugin Behavior

def test_search_queryset(product_factory):
    """Test search plugin modifies queryset"""
    product_factory.create_batch(5, name="Widget")
    product_factory.create_batch(3, name="Gadget")

    request = RequestFactory().get('/', {'q': 'Widget'})
    view = ProductListView(request=request)

    qs = view.get_queryset()
    # Search plugin should filter
    assert qs.count() == 5

Test Plugin Integration

def test_export_action_available(admin_site, product_admin):
    """Test export plugin adds actions"""
    assert any(isinstance(a, ExportCSVAction)
               for a in product_admin.bulk_actions)

Plugin Best Practices

1. Feature Advertising

Always advertise features your plugin provides:

@hookimpl
def djadmin_provides_features():
    return ['search', 'export']  # Clear declaration

2. Registry Pattern

Use isinstance() checks for flexible matching:

@hookimpl
def djadmin_get_action_view_mixins(action):
    return {
        ListAction: [SearchMixin],           # Specific class
        ListActionMixin: [PaginationMixin],  # View-type (broader)
        BaseAction: [LoggingMixin],              # All actions
    }

3. Defensive Checks

Check for configuration before acting:

@hookimpl
def djadmin_get_action_view_attributes(action):
    # Only add if configured
    if hasattr(action.model_admin, 'search_fields'):
        if action.model_admin.search_fields:
            return {ListActionMixin: {'search_fields': ...}}
    return {}

4. Return Early

Return empty dict/list if hook doesn't apply:

@hookimpl
def djadmin_get_action_view_mixins(action):
    # Not applicable to this action type
    if not isinstance(action, ListAction):
        return {}

    return {ListAction: [MyMixin]}

5. Documentation

Document what your plugin provides:

"""
Search Plugin for django-admin-deux

Provides:
- Feature: 'search'
- Mixins: SearchMixin for ListView
- Querystring: ?q=<query>

Configuration:
    class MyModelAdmin(ModelAdmin):
        search_fields = ['name', 'description']
"""

Debugging Plugins

List Registered Plugins

from djadmin.plugins import pm
print(pm.list_plugin_distinfo())

Check Hook Results

from djadmin.plugins import pm

# See what all plugins return
results = pm.hook.djadmin_provides_features()
print(results)  # [[...], [...], ...]

# See what mixins are registered
results = pm.hook.djadmin_get_action_view_mixins(action=my_action)
for registry in results:
    print(registry)

Trace Hook Calls

from djadmin.plugins import pm

# Enable tracing
with pm.trace_hook():
    pm.hook.djadmin_get_action_view_mixins(action=action)

Verify Plugin Discovery

# Check if plugin module was found
from djadmin.plugins import pm
print(pm.list_plugin_distinfo())
# Should show myapp.djadmin_hooks if properly registered

Plugin Distribution

Packaging

# setup.py or pyproject.toml
[project]
name = "django-admin-deux-search"
version = "1.0.0"
dependencies = ["django-admin-deux>=1.0"]

[project.entry-points]
# No entry points needed - djp discovers via INSTALLED_APPS

Installation

pip install django-admin-deux-search

Activation

# settings.py
INSTALLED_APPS = [
    'djadmin',
    'djadmin_search',  # Activates plugin
    'myapp',
]

Comparison with Django Signals

Aspect Django Signals djp/pluggy Hooks
Purpose Event notification Feature extension
Discovery Manual connection Auto-discovery
Return values Ignored Collected and used
Ordering Unpredictable Controlled
Type safety None Hook specs
Use case Loose coupling Plugin architecture

Source Code: - Hook specifications: djadmin/plugins/__init__.py - Core plugin: djadmin/plugins/core/djadmin_hooks.py - Theme plugin: djadmin/plugins/theme/djadmin_hooks.py

External: - djp Documentation - pluggy Documentation - pytest plugin system

Conceptual: - Action System - What plugins extend - ViewFactory - How plugins inject code - Design Decisions - Why plugins