Skip to content

Plugin-Driven INSTALLED_APPS

Status: Implemented Function: djadmin.djadmin_apps() Related: Milestone 4 - Developer Experience

Overview

djadmin_apps() provides zero-configuration INSTALLED_APPS setup for django-admin-deux and its plugins. Instead of manually managing app lists and ordering, plugins declare their dependencies via hooks, and djadmin_apps() automatically resolves the correct order.

Quick Start

# settings.py
from djadmin import djadmin_apps

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # Your apps
    'myapp',
    'anotherapp',
] + djadmin_apps()  # Automatically adds djadmin + all plugins

That's it! No manual plugin configuration needed.

What It Does

djadmin_apps() performs these steps:

  1. Discovers third-party plugins via Python entrypoints (project.entry-points.djadmin)
  2. Registers built-in plugins (core and theme) explicitly
  3. Calls plugin hooks to get required apps (djadmin_get_required_apps())
  4. Resolves ordering using First, Before, After, Position modifiers
  5. Removes duplicates while preserving order
  6. Returns ordered list of app names

How Plugins Declare Apps

Built-in Plugins

Built-in plugins (core, theme) are explicitly registered in djadmin_apps() and implement the hook:

Core Plugin (djadmin/plugins/core/djadmin_hooks.py):

from djadmin.plugins import hookimpl

@hookimpl
def djadmin_get_required_apps():
    """Core plugin apps."""
    return [
        'djadmin.plugins.core',
    ]

Theme Plugin (djadmin/plugins/theme/djadmin_hooks.py):

from djadmin.plugins import hookimpl
from djadmin.plugins.modifiers import First

@hookimpl
def djadmin_get_required_apps():
    """Theme plugin must load first for template precedence."""
    return [
        First('djadmin.plugins.theme'),
    ]

Third-Party Plugins

Third-party plugins declare themselves via entrypoints in pyproject.toml:

Step 1: Declare entrypoint

# your_plugin/pyproject.toml
[project.entry-points.djadmin]
your_plugin = "your_plugin.djadmin_hooks"

Step 2: Implement hook

# your_plugin/djadmin_hooks.py
from djadmin.plugins import hookimpl
from djadmin.plugins.modifiers import After

@hookimpl
def djadmin_get_required_apps():
    """Required apps for your plugin."""
    return [
        After('your_plugin'),  # Load after core apps
        'dependency_app',      # Additional dependency (no ordering)
    ]

Step 3: Install plugin

pip install your-djadmin-plugin

That's it! djadmin_apps() will discover your plugin automatically.

Ordering Modifiers

Plugins use modifiers to control app ordering:

First(app_name)

Places app at the very beginning of the list.

Use case: Themes that need template precedence

from djadmin.plugins.modifiers import First

@hookimpl
def djadmin_get_required_apps():
    return [
        First('my_theme'),  # Must load first
    ]

Result:

['my_theme', 'djadmin', 'djadmin.plugins.core', ...]

Before(app_name)

Places app in the "before" bucket, which loads before core apps.

Use case: Dependencies that djadmin relies on

from djadmin.plugins.modifiers import Before

@hookimpl
def djadmin_get_required_apps():
    return [
        Before('formset'),  # formset must load before djadmin
    ]

Result:

['formset', 'djadmin', 'djadmin.plugins.core', ...]

After(app_name)

Places app in the "after" bucket, which loads after core apps.

Use case: Plugins that extend djadmin features

from djadmin.plugins.modifiers import After

@hookimpl
def djadmin_get_required_apps():
    return [
        After('my_plugin'),  # Load after core
    ]

Result:

['djadmin', 'djadmin.plugins.core', 'my_plugin', ...]

Position(app_name, before=None, after=None)

Places app relative to another specific app.

Use case: Complex ordering constraints

from djadmin.plugins.modifiers import Position

@hookimpl
def djadmin_get_required_apps():
    return [
        Position('my_plugin', after='some_other_plugin'),
    ]

Result:

['..., 'some_other_plugin', 'my_plugin', ...]

Note: Both before and after can be specified for precise positioning.

No Modifier (Default Bucket)

Apps without modifiers go in the "default" bucket with no ordering constraints.

@hookimpl
def djadmin_get_required_apps():
    return [
        'django_filters',  # No special ordering
    ]

Ordering Resolution Algorithm

djadmin_apps() uses a bucket-based algorithm:

# 1. Categorize apps into buckets
first = []      # First('app')
before = []     # Before('app')
default = []    # Plain 'app' or djadmin core
after = []      # After('app')
position = []   # Position('app', before/after='...')

# 2. Combine buckets
combined = first + before + default + after

# 3. Insert Position apps relative to existing apps
for pos_app in position:
    find_position_in_combined(pos_app)
    insert_at_correct_position(pos_app)

# 4. Remove duplicates (preserves first occurrence)
unique_apps = remove_duplicates(combined)

# 5. Return final list
return unique_apps

Examples

Example 1: Theme Plugin

A theme plugin needs to load first for template override:

# my_theme/djadmin_hooks.py
from djadmin.plugins import hookimpl
from djadmin.plugins.modifiers import First

@hookimpl
def djadmin_get_required_apps():
    return [
        First('my_theme'),  # Template precedence
    ]

Entrypoint:

[project.entry-points.djadmin]
my_theme = "my_theme.djadmin_hooks"

Result in INSTALLED_APPS:

[
    # ... Django apps ...
    'my_theme',              # First (theme)
    'djadmin.plugins.theme', # First (default theme) - duplicate removed
    'djadmin',               # Core
    'djadmin.plugins.core',  # Core plugin
]

Example 2: Formset Plugin (Template Override Pattern)

A plugin that needs to override templates must load BEFORE djadmin core.

CRITICAL: Plugins with templates must use Before() to ensure Django's template loader finds their templates first.

# djadmin_formset/djadmin_hooks.py
from djadmin.plugins import hookimpl
from djadmin.plugins.modifiers import Before

@hookimpl
def djadmin_get_required_apps():
    """
    CRITICAL: djadmin_formset must load BEFORE djadmin to override templates.
    Django's template loader uses first-match resolution, so our templates in
    djadmin_formset/templates/djadmin/actions/edit.html must be found before
    djadmin/templates/djadmin/actions/edit.html.
    """
    return [
        Before('formset'),              # django-formset needs early loading
        Before('djadmin_formset'),      # Our plugin app - MUST be before djadmin for template overrides
    ]

Entrypoint:

[project.entry-points.djadmin]
djadmin_formset = "djadmin_formset.djadmin_hooks"

Result:

[
    # ... Django apps ...
    'djadmin.plugins.theme',    # First (theme template overrides)
    'formset',                  # Before bucket (dependency)
    'djadmin_formset',          # Before bucket (CRITICAL for template overrides!)
    'djadmin',                  # Core (templates loaded after plugins)
    'djadmin.plugins.core',     # Core plugin
]

Why This Matters: - Django's APP_DIRS template loader searches through INSTALLED_APPS in order - Both djadmin and djadmin_formset have templates at djadmin/actions/edit.html - djadmin_formset version uses <django-formset> web components - djadmin version uses standard <form> tags - Without Before(), the core template loads first, breaking FormCollection rendering

Example 3: Filter Plugin

A plugin with no special ordering requirements:

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

@hookimpl
def djadmin_get_required_apps():
    return [
        'django_filters',  # No ordering constraint
    ]

Entrypoint:

[project.entry-points.djadmin]
djadmin_filters = "djadmin_filters.djadmin_hooks"

Result:

[
    # ... Django apps ...
    'djadmin',               # Core
    'djadmin.plugins.core',  # Core plugin
    'django_filters',        # Default bucket (no constraint)
]

Example 4: Complex Positioning

A plugin that must load after another plugin:

# enhanced_filters/djadmin_hooks.py
from djadmin.plugins import hookimpl
from djadmin.plugins.modifiers import Position

@hookimpl
def djadmin_get_required_apps():
    return [
        Position('enhanced_filters', after='django_filters'),
    ]

Result:

[
    # ... Django apps ...
    'djadmin',
    'django_filters',        # Base filters
    'enhanced_filters',      # After django_filters
]

Plugin Discovery

How Discovery Works

At settings import time (before Django starts):

  1. djadmin_apps() is called from settings.py
  2. Built-in plugins (core, theme) are explicitly registered:
    from djadmin.plugins.core import djadmin_hooks as core_hooks
    from djadmin.plugins.theme import djadmin_hooks as theme_hooks
    pm.register(core_hooks)
    pm.register(theme_hooks)
    
  3. Third-party plugins discovered via entrypoints:
    pm.load_setuptools_entrypoints('djadmin')
    
  4. All plugins call djadmin_get_required_apps() hook
  5. Apps collected and ordered

Why Entrypoints?

The Chicken-and-Egg Problem: - djadmin_apps() runs in settings.py (before Django starts) - Traditional plugin discovery happens in AppConfig.ready() (after Django starts) - Can't discover plugins after apps are loaded

The Solution: - Python entrypoints are available at import time - Standard packaging mechanism (setuptools, pip) - Discoverable before Django starts - Built-in plugins registered explicitly (no entrypoint needed)

Entrypoint Format

# pyproject.toml
[project.entry-points.djadmin]
plugin_name = "module.path.to.djadmin_hooks"

Requirements: - Entry point group: djadmin - Entry point name: Any unique name (conventionally: package name) - Entry point value: Dotted path to djadmin_hooks module

Example:

[project.entry-points.djadmin]
my_plugin = "my_plugin.djadmin_hooks"

Django will load my_plugin.djadmin_hooks and register it with the plugin manager.

Usage Patterns

Standard Setup

# settings.py
from djadmin import djadmin_apps

INSTALLED_APPS = [
    'django.contrib.admin',
    # ... standard Django apps ...
    'myapp',
] + djadmin_apps()

With Custom Apps List

# settings.py
from djadmin import djadmin_apps

DJANGO_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    # ...
]

THIRD_PARTY_APPS = [
    'rest_framework',
    'crispy_forms',
]

LOCAL_APPS = [
    'myapp',
    'anotherapp',
]

INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + djadmin_apps()

Conditional Inclusion

# settings.py
from djadmin import djadmin_apps

INSTALLED_APPS = [
    'django.contrib.admin',
    # ...
]

# Only use djadmin in development
if DEBUG:
    INSTALLED_APPS += djadmin_apps()

Troubleshooting

ImproperlyConfigured: Could not position app

Error:

ImproperlyConfigured: Could not position 'my_app' after 'other_app':
'other_app' not found in combined apps.

Cause: Position(my_app, after='other_app') but other_app isn't in the list.

Solutions: 1. Check other_app is provided by a plugin hook 2. Verify other_app spelling is correct 3. Use After() instead if relative positioning isn't critical

Plugin not discovered

Symptoms: Plugin apps not in INSTALLED_APPS

Check: 1. Entrypoint declared correctly in pyproject.toml:

python -c "from importlib.metadata import entry_points; print([ep for ep in entry_points(group='djadmin')])"

  1. Plugin installed:

    pip list | grep your-plugin
    

  2. Hook module exists:

    python -c "import your_plugin.djadmin_hooks"
    

  3. Hook implemented:

    python -c "from your_plugin import djadmin_hooks; print(hasattr(djadmin_hooks, 'djadmin_get_required_apps'))"
    

Duplicate apps

Symptoms: Same app appears multiple times in INSTALLED_APPS

Cause: You added the app manually AND plugin provides it

Solution: Let djadmin_apps() handle it - remove manual entry:

# ❌ BAD - Duplicates
INSTALLED_APPS = [
    'django_filters',  # Manual
    # ...
] + djadmin_apps()  # Also provides django_filters

# ✅ GOOD - No duplicates
INSTALLED_APPS = [
    # ... Django apps only
] + djadmin_apps()  # Provides django_filters automatically

Order is wrong

Symptoms: Template override not working, app loading in wrong order

Debug: 1. Print the result:

apps = djadmin_apps()
print(apps)

  1. Check modifier usage:
  2. Use First() for themes (template precedence)
  3. Use Before() for dependencies (load before djadmin)
  4. Use After() for extensions (load after djadmin)

  5. Verify plugin hook is called:

    from djadmin.plugins import pm
    results = pm.hook.djadmin_get_required_apps()
    print(results)
    

Best Practices

DO: Use modifiers appropriately

# Theme plugin - needs First
First('my_theme')

# Dependency - needs Before
Before('formset')

# Extension - use After
After('my_plugin')

# No special needs - no modifier
'django_filters'

DO: Keep dependencies minimal

# ✅ GOOD - Only essential deps
@hookimpl
def djadmin_get_required_apps():
    return [
        'required_lib',
    ]

# ❌ BAD - Unnecessary deps
@hookimpl
def djadmin_get_required_apps():
    return [
        'optional_lib',  # Should be user's choice
        'test_lib',      # Test dependency, not runtime
    ]

DO: Document why modifiers are needed

@hookimpl
def djadmin_get_required_apps():
    """
    Required apps for formset integration.

    - formset: Must load before djadmin (Before) because djadmin
      imports formset classes during view generation.
    """
    return [
        Before('formset'),
    ]

DON'T: Use Position() unless necessary

# ❌ AVOID - Position is complex
Position('my_app', after='specific_app')

# ✅ PREFER - Use buckets when possible
After('my_app')

DON'T: Return djadmin core

# ❌ BAD - djadmin_apps() adds 'djadmin' automatically
@hookimpl
def djadmin_get_required_apps():
    return ['djadmin', 'my_plugin']

# ✅ GOOD - Only return your plugin's apps
@hookimpl
def djadmin_get_required_apps():
    return ['my_plugin']

DON'T: Modify INSTALLED_APPS manually after djadmin_apps()

# ❌ BAD - Order can be broken
INSTALLED_APPS += djadmin_apps()
INSTALLED_APPS.insert(0, 'my_theme')  # Might break ordering

# ✅ GOOD - Let djadmin_apps() handle ordering
INSTALLED_APPS += djadmin_apps()

Advanced Usage

Conditional Apps

# your_plugin/djadmin_hooks.py
from djadmin.plugins import hookimpl
from django.conf import settings

@hookimpl
def djadmin_get_required_apps():
    """Conditionally include apps based on settings."""
    apps = ['my_plugin']

    # Add optional feature app if enabled
    if getattr(settings, 'MY_PLUGIN_FEATURE_X', False):
        apps.append('my_plugin.feature_x')

    return apps

Debug Mode

# settings.py
from djadmin import djadmin_apps

# Show what djadmin_apps() would add
if DEBUG:
    apps = djadmin_apps()
    print("djadmin_apps() adding:", apps)

INSTALLED_APPS = [
    # ...
] + apps

Testing Without djadmin_apps()

# tests/settings.py
INSTALLED_APPS = [
    'django.contrib.contenttypes',
    'django.contrib.auth',
    'djadmin',
    'djadmin.plugins.core',
    # Manually specify for testing
]

Performance

Discovery time: <50ms - Entrypoint loading: ~20ms - Hook calls: ~10ms - Ordering resolution: ~20ms

Caching: Not cached (runs once at settings import)

Impact: Negligible (<100ms added to Django startup)

Migration Guide

From Manual INSTALLED_APPS

Before:

INSTALLED_APPS = [
    'django.contrib.admin',
    # ...
    'djadmin',
    'djadmin.plugins.core',
    'djadmin.plugins.theme',
    'formset',
    'django_filters',
    'my_plugin',
]

After:

from djadmin import djadmin_apps

INSTALLED_APPS = [
    'django.contrib.admin',
    # ... your apps only
] + djadmin_apps()

From Old Plugin Format

If your plugin previously required manual INSTALLED_APPS setup:

Before (in docs):

Add to INSTALLED_APPS:
- formset
- my_plugin

After (in code):

# my_plugin/djadmin_hooks.py
from djadmin.plugins import hookimpl
from djadmin.plugins.modifiers import Before

@hookimpl
def djadmin_get_required_apps():
    return [
        Before('formset'),
        'my_plugin',
    ]

# my_plugin/pyproject.toml
[project.entry-points.djadmin]
my_plugin = "my_plugin.djadmin_hooks"

See Also