Quickstart

Get up and running with ReverseRelationAdminMixin in just a few steps.

See also

Explore Concepts & Architecture for the lifecycle of the injected virtual fields and dive into Recipes for end-to-end examples, including permissions and validation hooks.

Install the package

python -m pip install django-admin-reversefields
uv pip install django-admin-reversefields

Note

The docs extras (sphinx extensions) are listed in docs/requirements.txt. Install them alongside the package when you plan to build the documentation locally.

Wire up your admin

Import the mixin and declare a mapping of virtual field names to ReverseRelationConfig objects. Each configuration describes which reverse-side model and ForeignKey should be controlled from the parent admin.

Required imports in admin.py
from django.contrib import admin
from django.db.models import Q

from django_admin_reversefields.mixins import (
    ReverseRelationAdminMixin,
    ReverseRelationConfig,
)

Register your ModelAdmin by inheriting from the mixin and declaring at least one reverse relation:

Minimal admin exposing reverse bindings with qualifying filters
def unbound_or_current_company(queryset, instance, _request):
    """Return company-scoped choices for reverse FK bindings.

    Args:
        queryset: Base queryset for reverse-side objects.
        instance: Company currently edited in the admin form.
        _request: Active admin request (unused).

    Returns:
        Filtered queryset containing objects that are either unbound or already
        bound to the current company.
    """
    if instance and instance.pk:
        return queryset.filter(Q(company__isnull=True) | Q(company=instance))
    return queryset.filter(company__isnull=True)


def unbound_or_current_department(queryset, instance, _request):
    """Return department-scoped choices for reverse FK bindings.

    Args:
        queryset: Base queryset for reverse-side objects.
        instance: Department currently edited in the admin form.
        _request: Active admin request (unused).

    Returns:
        Filtered queryset containing objects that are either unbound or already
        bound to the current department.
    """
    if instance and instance.pk:
        return queryset.filter(Q(department__isnull=True) | Q(department=instance))
    return queryset.filter(department__isnull=True)


@admin.register(Company)
class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
    """
    Company admin following quickstart patterns.

    Following the quickstart guide step-by-step:
    1. Inherit from ReverseRelationAdminMixin and admin.ModelAdmin
    2. Declare reverse_relations dict with virtual field names as keys
    3. Include virtual field names in fieldsets
    """

    # Step 1: Declare reverse_relations dict keyed by virtual field name
    reverse_relations = {
        # Multi-select: manage which departments belong to this company
        "departments": ReverseRelationConfig(
            model=Department,
            fk_field="company",
            multiple=True,
            limit_choices_to=unbound_or_current_company,
            help_text=(
                "Choices are limited to departments that are unassigned or already "
                "assigned to this company."
            ),
        ),
        # Multi-select: manage which projects belong to this company
        "projects": ReverseRelationConfig(
            model=Project,
            fk_field="company",
            multiple=True,
            limit_choices_to=unbound_or_current_company,
            help_text=(
                "Choices are limited to projects that are unassigned or already "
                "assigned to this company."
            ),
        ),
        # Single-select: bind one CompanySettings instance (OneToOne)
        "settings": ReverseRelationConfig(
            model=CompanySettings,
            fk_field="company",
            multiple=False,
        ),
  1. reverse_relations is a dict keyed by virtual field name.

  2. Each ReverseRelationConfig declares the reverse model, the ForeignKey that points back, and any optional UI behaviour (labels, widgets, ordering).

  3. Include the virtual field names inside fieldsets (or fields) so Django renders them on the form. When neither is declared, Django will render all fields automatically.

Warning

If the underlying reverse ForeignKey is null=False you must set required=True on the virtual field to avoid django.db.IntegrityError when a user attempts to unbind the relation. See Caveats for details.

Limit choices per request

The mixin resolves querysets on demand, so your limiter can be request-aware and object-aware while still including items that are already bound to the current instance.

from django.db.models import Q

def unbound_or_current(queryset, instance, request):
    """Offer unassigned rows plus rows already bound to this company."""
    if instance and instance.pk:
        return queryset.filter(Q(company__isnull=True) | Q(company=instance))
    return queryset.filter(company__isnull=True)

class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
    reverse_relations = {
        "department_binding": ReverseRelationConfig(
            model=Department,
            fk_field="company",
            limit_choices_to=unbound_or_current,
            help_text=(
                "Choices are limited to departments that are unassigned or "
                "already assigned to this company."
            ),
        )
    }
Configuration highlights
  • limit_choices_to accepts either a callable (queryset, instance, request) -> queryset or a dict that is passed to filter().

  • Add short docstrings to limiter helpers and pair them with help_text on the virtual field so users understand why some rows are not selectable.

  • multiple=True switches a field to a ModelMultipleChoiceField and synchronises the entire set on save.

  • widget can be any Django form widget (including AJAX options such as Django Autocomplete Light). See Advanced for an end-to-end walkthrough.

  • bulk=True enables bulk operations using .update() for better performance with large datasets, but bypasses model signals.

Enable bulk operations for performance

For large datasets where model signals aren’t required, enable bulk mode to use Django’s .update() method instead of individual saves:

class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
    reverse_relations = {
        # Single-select with bulk operations
        "department_binding": ReverseRelationConfig(
            model=Department,
            fk_field="company",
            bulk=True,  # Use .update() for better performance
            limit_choices_to=unbound_or_current,
        ),
        # Multi-select with bulk operations
        "assigned_projects": ReverseRelationConfig(
            model=Project,
            fk_field="company",
            multiple=True,
            bulk=True,  # Bulk operations for multiple selections
            ordering=("name",),
        )
    }

Warning

When to use bulk mode:

  • ✅ Large datasets (hundreds/thousands of objects)

  • ✅ Performance is critical

  • ✅ No dependency on model signals (pre_save, post_save, etc.)

When NOT to use bulk mode:

  • ❌ Your models rely on pre_save or post_save signals

  • ❌ You need granular error handling per object

  • ❌ Small datasets where performance isn’t a concern

What happens on save?

Form lifecycle
  1. During get_form() the mixin injects the virtual fields and computes their querysets.

  2. The generated form sets initial selections based on currently bound objects.

  3. When reverse_permissions_enabled is true, permission policies run at render, validation, and persistence time to guard the field.

  4. On form.save(), the mixin applies transactional updates—unbind first, then bind—to keep the database consistent. When bulk=True, operations use .update() for better performance. For details, see Concepts & Architecture.

Next steps

Build on the basics