Manage reverse ForeignKey/OneToOne bindings directly from a parent model’s Django admin form using a small, declarative mixin.
- Add virtual fields to your
ModelAdminto bind/unbind reverse-side rows - Keep selections in sync with transactional, unbind-before-bind updates
- Use stock admin widgets or plug in Unfold/DAL/custom widgets
- Optional, flexible permission gating with clear UX (hide/disable)
pip install django-admin-reversefieldsSupported: Django 4.2/5.0/5.1/5.2; Python 3.10–3.13.
from django.contrib import admin
from django.db.models import Q
from django_admin_reversefields.mixins import (
ReverseRelationAdminMixin,
ReverseRelationConfig,
)
from .models import Company, Department, Project
def unbound_or_current(qs, instance, request):
if instance and instance.pk:
return qs.filter(Q(company__isnull=True) | Q(company=instance))
return qs.filter(company__isnull=True)
@admin.register(Company)
class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
reverse_relations = {
# Single-select: bind exactly one Department via its FK to Company
"department_binding": ReverseRelationConfig(
model=Department,
fk_field="company",
limit_choices_to=unbound_or_current,
),
# Multi-select: manage the entire set of Projects pointing at the Company
"assigned_projects": ReverseRelationConfig(
model=Project,
fk_field="company",
multiple=True,
# optional: ordering=("name",),
),
}
fieldsets = (("Relations", {"fields": ("department_binding", "assigned_projects")}),)- Include each virtual field name (e.g.
"department_binding") infieldsetsorfieldsso the admin template renders it (or omit bothfieldsandfieldsetsand Django will render all fields, including the injected virtual fields). - Limiters run per request/object; commonly: “unbound or currently bound”.
- Reverse fields are virtual
ModelChoiceField/ModelMultipleChoiceFieldinstances that point to the reverse-side model and its ForeignKey back to the admin’s model. - Querysets and initial values are computed per request/object.
- On save, the mixin synchronizes the reverse-side ForeignKey(s) to match the submitted selection.
- Single-select: sets the chosen row’s FK to the parent and unbinds any other rows pointing to it.
- Multi-select: represents the entire desired set; rows not in the selection are unbound before binds.
- Transactions: by default
reverse_relations_atomic=Truewraps all updates in onetransaction.atomic()block and applies unbinds before binds to minimize uniqueness conflicts.
Performance: enable bulk=True on a ReverseRelationConfig to use .update() for unbind/bind operations. This improves performance with large datasets but bypasses model signals. Use only if your app doesn’t depend on pre_save/post_save on the reverse model.
Important: for single-select, unbinding others requires the reverse FK to be null=True, or set required=True on the virtual field when it must never be empty; otherwise an unbind can raise IntegrityError.
Enable enforcement:
class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
reverse_permissions_enabled = True
reverse_permission_mode = "disable" # or "hide"- Precedence for allow/deny:
- Per-field
ReverseRelationConfig.permission reverse_permission_policy(admin-wide)- Default
user.has_perm("app.change_model")on the reverse model
- Per-field
- Error message precedence: field override → per-field policy object → global policy object → default
- Disable vs hide:
- "disable": render read-only and ignore posted changes. To avoid spurious validation, the mixin sets
required=Falseon disabled reverse fields so forms won’t raise “This field is required.” when there is no initial value. - "hide": remove the field entirely.
- "disable": render read-only and ignore posted changes. To avoid spurious validation, the mixin sets
- Optional: set
reverse_render_uses_field_policy=Trueto have render-time visibility/disabled state decided by your per-field/global policy (called withselection=None).
Hidden/disabled fields are always ignored on save, so crafted POSTs cannot change unauthorized reverse fields.
Import:
from django_admin_reversefields.mixins import ReverseRelationAdminMixin, ReverseRelationConfigReverseRelationConfig (per virtual field):
model: reverse-sidemodels.Modelthat holds the ForeignKey to the admin modelfk_field: name of that ForeignKey onmodellabel,help_text: optional display stringsrequired: enforce non-empty selection (default False)multiple: multi-select that syncs many rows (default False)limit_choices_to: callable(qs, instance, request) -> qsordictpassed to.filter(**dict)widget: widget instance or class; defaults to adminSelect/FilteredSelectMultipleordering: iterable for.order_by()clean(instance, selection, request): optional domain validation; raiseforms.ValidationErrorto blockpermission: optional policy (callable or object withhas_perm(...)) to allow/deny editspermission_denied_message: message used when a denial becomes a field errorbulk: when True, perform unbind/bind via.update()(bypasses model signals)
Mixin knobs:
reverse_relations: mapping of virtual field name → configreverse_relations_atomic: wrap all updates in one transaction (default True)reverse_permissions_enabled: enforce permission checks (default False)reverse_permission_mode: "disable" | "hide"reverse_permission_policy: optional global policyreverse_render_uses_field_policy: use per-field/global policy at render time (selection=None)
We use uv for tooling.
uv sync— install project + docs depsuv run ruff check .— lintuv run django-admin testoruv run python manage.py test— testsuv run sphinx-build -b html docs docs/_build/html -W— docs build
Release:
uv build
Twine upload dist/*