Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,4 @@ target/
.vagrant/
.cursor/*
.kiro/*
docs/_autosummary/
48 changes: 33 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# django-admin-reversefields

[![PyPI - Downloads](https://img.shields.io/pypi/dw/django-admin-reversefields)](https://pypi.org/project/django-admin-reversefields/)
[![PyPI - Version](https://img.shields.io/pypi/v/django-admin-reversefields)](https://pypi.org/project/django-admin-reversefields/)

Manage reverse ForeignKey/OneToOne bindings directly from a parent model’s Django admin form using a small, declarative mixin.

- Add virtual fields to your `ModelAdmin` to bind/unbind reverse-side rows
Expand All @@ -15,7 +18,7 @@ Manage reverse ForeignKey/OneToOne bindings directly from a parent model’s Dja
pip install django-admin-reversefields
```

Supported: Django 4.2/5.0/5.1; Python 3.10–3.13.
Supported: Django 4.2/5.0/5.1/5.2; Python 3.10–3.13.

---

Expand All @@ -29,29 +32,38 @@ from django_admin_reversefields.mixins import (
ReverseRelationAdminMixin,
ReverseRelationConfig,
)
from .models import Company, Department, Project


def only_unbound_or_current(qs, instance, request):
def unbound_or_current(qs, instance, request):
if instance and instance.pk:
return qs.filter(Q(service__isnull=True) | Q(service=instance))
return qs.filter(service__isnull=True)
return qs.filter(Q(company__isnull=True) | Q(company=instance))
return qs.filter(company__isnull=True)


@admin.register(Service)
class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
@admin.register(Company)
class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
reverse_relations = {
"site_binding": ReverseRelationConfig(
model=Site,
fk_field="service",
limit_choices_to=only_unbound_or_current,
)
# 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 = (("Binding", {"fields": ("site_binding",)}),)
fieldsets = (("Relations", {"fields": ("department_binding", "assigned_projects")}),)
```

- Include the virtual field name (e.g. `"site_binding"`) in `fieldsets` so Django renders it.
- Limiters run per request/object; use them to include unbound items plus the current binding.
- Include each virtual field name (e.g. `"department_binding"`) in `fieldsets` or `fields` so the admin template renders it (or omit both `fields` and `fieldsets` and Django will render all fields, including the injected virtual fields).
- Limiters run per request/object; commonly: “unbound or currently bound”.

---

Expand All @@ -64,6 +76,8 @@ class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
- Multi-select: represents the entire desired set; rows not in the selection are unbound before binds.
- Transactions: by default `reverse_relations_atomic=True` wraps all updates in one `transaction.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`.

---
Expand All @@ -88,6 +102,8 @@ class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
- "hide": remove the field entirely.
- Optional: set `reverse_render_uses_field_policy=True` to have render-time visibility/disabled state decided by your per-field/global policy (called with `selection=None`).

Hidden/disabled fields are always ignored on save, so crafted POSTs cannot change unauthorized reverse fields.

---

## API surface
Expand All @@ -111,6 +127,7 @@ from django_admin_reversefields.mixins import ReverseRelationAdminMixin, Reverse
- `clean(instance, selection, request)`: optional domain validation; raise `forms.ValidationError` to block
- `permission`: optional policy (callable or object with `has_perm(...)`) to allow/deny edits
- `permission_denied_message`: message used when a denial becomes a field error
- `bulk`: when True, perform unbind/bind via `.update()` (bypasses model signals)

Mixin knobs:

Expand All @@ -126,11 +143,12 @@ Mixin knobs:
## Recipes and docs

- [Quickstart](https://tnware.github.io/django-admin-reversefields/quickstart.html)
- [Concepts](https://tnware.github.io/django-admin-reversefields/concepts.html)
- [Core concepts](https://tnware.github.io/django-admin-reversefields/core-concepts.html)
- [Permissions](https://tnware.github.io/django-admin-reversefields/permissions-guide.html)
- [Architecture](https://tnware.github.io/django-admin-reversefields/architecture.html)
- [Recipes](https://tnware.github.io/django-admin-reversefields/recipes.html)
- [Caveats](https://tnware.github.io/django-admin-reversefields/caveats.html)
- [Rendering & Visibility](https://tnware.github.io/django-admin-reversefields/rendering.html)

---

Expand Down
9 changes: 9 additions & 0 deletions docs/_templates/autosummary/class.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{{ objname }}
{{ '=' * objname|length }}

.. currentmodule:: {{ module }}

.. autoclass:: {{ fullname }}
:members:
:show-inheritance:

10 changes: 10 additions & 0 deletions docs/_templates/autosummary/class_admin.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{{ objname }}
{{ '=' * objname|length }}

.. currentmodule:: {{ module }}

.. autoclass:: {{ fullname }}
:members:
:show-inheritance:
:exclude-members: reverse_relations, reverse_relations_atomic, reverse_permissions_enabled, reverse_permission_policy, reverse_permission_mode

58 changes: 32 additions & 26 deletions docs/advanced.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Advanced
========

Advanced configuration examples and third-party widget integrations.

.. contents:: Page contents
:depth: 1
:local:

.. seealso::
The :ref:`recipe-ajax-widget` provides a complete, end-to-end recipe for
integrating third-party AJAX widgets.
Expand All @@ -23,37 +29,37 @@ Widgets
from django.contrib import admin
from dal import autocomplete
from django.db.models import Q
from myapp.models import MerakiNet, Site
from myapp.models import Company, Department

from django_admin_reversefields.mixins import ReverseRelationAdminMixin, ReverseRelationConfig


def meraki_network_site_queryset(queryset, instance, request):
"""Allow binding to sites that are unbound or already tied to ``instance``."""
def company_department_queryset(queryset, instance, request):
"""Allow binding to departments that are unbound or already tied to ``instance``."""
if instance and instance.pk:
return queryset.filter(Q(meraki__isnull=True) | Q(meraki=instance))
return queryset.filter(meraki__isnull=True)
return queryset.filter(Q(company__isnull=True) | Q(company=instance))
return queryset.filter(company__isnull=True)


class SiteAutocomplete(autocomplete.Select2QuerySetView):
class DepartmentAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
qs = Site.objects.all()
qs = Department.objects.all()
term = (self.q or "").strip()
if term:
qs = qs.filter(displayName__icontains=term)
return qs.order_by("displayName")
qs = qs.filter(name__icontains=term)
return qs.order_by("name")


@admin.register(MerakiNet)
class MerakiNetAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
@admin.register(Company)
class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
reverse_relations = {
"site_binding": ReverseRelationConfig(
model=Site,
fk_field="meraki",
"department_binding": ReverseRelationConfig(
model=Department,
fk_field="company",
widget=autocomplete.ModelSelect2(
url="site-autocomplete", attrs={"data-minimum-input-length": 2}
url="department-autocomplete", attrs={"data-minimum-input-length": 2}
),
limit_choices_to=meraki_network_site_queryset,
limit_choices_to=company_department_queryset,
)
}

Expand All @@ -76,12 +82,12 @@ Widgets

from unfold.widgets import UnfoldAdminSelectWidget

@admin.register(Service)
class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
@admin.register(Company)
class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
reverse_relations = {
"site_binding": ReverseRelationConfig(
model=Site,
fk_field="service",
"department_binding": ReverseRelationConfig(
model=Department,
fk_field="company",
widget=UnfoldAdminSelectWidget(),
)
}
Expand All @@ -94,12 +100,12 @@ Widgets

from unfold.widgets import UnfoldAdminSelect2Widget

@admin.register(Service)
class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
@admin.register(Company)
class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
reverse_relations = {
"site_binding": ReverseRelationConfig(
model=Site,
fk_field="service",
"department_binding": ReverseRelationConfig(
model=Department,
fk_field="company",
widget=UnfoldAdminSelect2Widget(),
)
}
44 changes: 16 additions & 28 deletions docs/api.rst
Original file line number Diff line number Diff line change
@@ -1,37 +1,25 @@
API
===

.. autoclass:: django_admin_reversefields.mixins.ReverseRelationConfig
:members:
:show-inheritance:
:no-index:
.. contents:: Page contents
:depth: 1
:local:

.. attribute:: bulk
:type: bool
:value: False
.. currentmodule:: django_admin_reversefields.mixins

When ``True``, use Django's ``.update()`` method for bind/unbind operations
instead of individual model saves. This provides better performance for large
datasets but bypasses model signals (``pre_save``, ``post_save``, etc.).
Overview
--------

.. warning::

Bulk operations bypass Django model signals. Only enable bulk mode when
your application doesn't rely on ``pre_save``, ``post_save``, or other
model signals for the reverse relationship model.
.. autosummary::
:toctree: _autosummary
:nosignatures:
:template: autosummary/class_admin.rst

**Default:** ``False`` (maintains backward compatibility)
ReverseRelationAdminMixin

**Performance Trade-offs:**

- **Advantages:** Reduced database round-trips, better performance with large datasets
- **Disadvantages:** No model signal processing, less granular error handling
.. autosummary::
:toctree: _autosummary
:nosignatures:

.. autoclass:: django_admin_reversefields.mixins.ReverseRelationAdminMixin
:members:
:show-inheritance:
:no-index:

.. autoclass:: django_admin_reversefields.mixins.ReversePermissionPolicy
:members:
:no-index:
ReverseRelationConfig
ReversePermissionPolicy
14 changes: 14 additions & 0 deletions docs/architecture.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Architecture
============

.. contents:: Page contents
:depth: 1
:local:

High-level components
---------------------

Expand Down Expand Up @@ -43,6 +47,9 @@ Form lifecycle
per-field visibility. Fields become hidden or disabled based on
``reverse_permission_mode``.

.. seealso::
The rendering flow and configuration options are detailed in :doc:`rendering`.

Validation and permissions
--------------------------

Expand Down Expand Up @@ -76,6 +83,13 @@ fields are synchronized inside a single transaction so either all :term:`binding
updated or none are. Unbinds happen before binds within each field to minimise
transient uniqueness conflicts on ``OneToOneField`` or ``unique`` ForeignKeys.

.. note:: ``commit=False``

If a form is saved with ``commit=False``, the mixin defers reverse updates
until :meth:`~django.contrib.admin.options.ModelAdmin.save_model`. The
payload of authorized reverse fields is stored on the form instance and
applied during the admin save hook.

Extensibility checklist
-----------------------

Expand Down
13 changes: 11 additions & 2 deletions docs/caveats.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,28 @@ The default widgets mirror the Django admin look-and-feel. Override
see the :doc:`advanced` gallery for end-to-end examples. For request-aware
choice limiting, revisit :doc:`querysets-and-widgets`.

For how fields are included in the form and when they are visible vs. disabled,
see :doc:`rendering`.

Permission interplay
--------------------

.. caution::

When ``reverse_permissions_enabled=True`` the mixin requires the user to pass
one of the configured :term:`policies <Policy>` before persisting changes.
Denied fields are ignored during save, so crafted POSTs cannot sidestep the
check. The render-time behaviour is controlled by
Denied fields are ignored during save (including hidden/disabled fields), so
crafted POSTs cannot sidestep the check. The render-time behaviour is controlled by
:attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_permission_mode`
(``"disable"`` or ``"hide"``). Refer back to :doc:`permissions-guide` for the
evaluation flow and error-message precedence.

By default, the render gate consults only a base/global permission. To let
per-field/global policies influence visibility/editability, set
``reverse_render_uses_field_policy=True`` on the admin.

See :doc:`rendering` for the end-to-end visibility and editability flow.

One-to-one specifics
--------------------

Expand Down
Loading
Loading