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¶
- Django app registration: App is in
INSTALLED_APPS - djp scans: Looks for
djadmin_hooks.pyin each app - Hook registration: Imports module and registers @hookimpl functions
- 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¶
- Context modification: Later plugins see earlier changes
- Queryset modification: Later filters apply to already-filtered queryset
- Mixin order: Earlier mixins override later mixins (MRO)
- Asset loading: Assets with
blocking=Trueload 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:
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¶
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¶
Activation¶
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 |
Related Documentation¶
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