"""Admin mixins for reverse ForeignKey editing.
``ReverseRelationAdminMixin`` injects virtual form fields that operate on
reverse ForeignKey relationships (fields that live on the related model) so
administrators can bind or unbind related objects directly from the parent
model's change form. The mixin coordinates four layers of behaviour:
* Declarative configuration with :class:`ReverseRelationConfig`
* Dynamic form construction with scoped querysets and widgets
* Optional permission gating for rendering and validation
* Transactional synchronization of the underlying ForeignKey rows
The default widgets match Django's admin widgets, but any custom widget can be
provided through configuration. Typical use-cases include assigning a single
related object (single-select) or managing a set of related objects
(multi-select) where the ForeignKey lives on the reverse-side model.
Permissions overview
--------------------
When ``reverse_permissions_enabled`` is True, three permission input shapes are
supported, checked in this precedence order:
1) Per-field ``ReverseRelationConfig.permission``
2) Global ``reverse_permission_policy`` on the admin
3) Fallback to ``request.user.has_perm("{app}.change_{model}")``
Accepted inputs for 1) and 2):
* Function (``PermissionCallable``): ``(request, obj, config, selection) -> bool``
* Policy object (``ReversePermissionPolicy``): callable object, may expose
``permission_denied_message``
* Object with only ``has_perm(...)`` method
Gates:
* Render gate (no selection): uses only the global policy (or fallback) to
hide/disable fields via ``reverse_permission_mode``.
* Validation gate (with selection): evaluates per-field or global policy on the
actual selection; attaches a field error when denied.
* Persist gate (save): filters the update payload so unauthorized fields are
ignored even if crafted in POST data.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Any, Protocol
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.db import models, transaction
from django.http import HttpRequest
PermissionCallable = Callable[
[HttpRequest, models.Model | None, "ReverseRelationConfig", Any | None],
bool,
]
[docs]
@dataclass(frozen=True, init=False)
class ReverseRelationConfig:
"""Configuration for a virtual reverse-relation admin field.
This dataclass describes how a virtual field should be rendered on an admin
form to manage a reverse ForeignKey relationship. The virtual field does not
exist on the model; instead, it controls one or more rows on the reverse-side
model that hold a ForeignKey pointing back to the current object.
Attributes:
model (type[models.Model]):
The reverse-side Django model that holds the ForeignKey.
fk_field (str):
The name of the ForeignKey field on the reverse-side model (the one
specified in ``model``) that points back to the current admin
object.
label (str | None):
Optional human-friendly label for the form field. If omitted, a
label is derived from ``model._meta.verbose_name`` (or plural when
``multiple`` is True).
help_text (str | None):
Optional help text displayed with the form field.
required (bool):
Whether a selection is required. If True, the form will enforce that
at least one value is selected (for multi) or a value is present
(for single).
multiple (bool):
If True, a multi-select is rendered and the resulting set of objects
on the reverse side will be synchronized on save. If False, a
single-select is rendered and only one object may point to the
current instance (others will be unbound on save).
limit_choices_to (Callable | dict | None):
Either a callable ``(queryset, instance, request) -> queryset`` that
can apply dynamic, per-request filtering (recommended), or a dict
used as ``queryset.filter(**dict)`` for static filtering.
Common usage: include only unbound objects, plus those already bound
to the current instance.
widget (forms.Widget | type[forms.Widget] | None):
Optional widget instance or class to use for rendering. Defaults to
Django's ``forms.Select`` for single-select and
``FilteredSelectMultiple`` for multi-select. You can supply Unfold,
DAL, or other custom widgets here.
ordering (Iterable[str] | None):
Optional ordering to apply to the limited queryset (e.g.,
``("displayName",)``).
clean (Callable[[models.Model, Any, HttpRequest], None] | None):
Optional per-field validation hook. When provided, it is invoked
from the derived form's ``clean()`` with
``(instance, selection, request)``. Raise ``forms.ValidationError``
to attach an error to this field and block save; return ``None`` for
success.
permission (ReversePermissionPolicy | PermissionCallable | object | None):
Optional per-field permission policy controlling whether the user
may modify this virtual field. Supported values include:
- A callable ``(request, obj, config, selection) -> bool``
- An object with ``has_perm(request, obj, config, selection) -> bool``
- An object implementing ``__call__`` following
:class:`ReversePermissionPolicy`
Policy objects may expose ``permission_denied_message`` for UI
feedback during validation.
permission_denied_message (str | None):
Optional custom error message attached to the field when a
selection is denied by permission checks during form validation.
bulk (bool):
When True, use Django's .update() method for bind/unbind operations
instead of individual model saves. This bypasses model signals
(pre_save, post_save, etc.) but provides better performance for
large datasets. Defaults to False for backward compatibility.
Example:
>>> ReverseRelationConfig(
... model=Site,
... fk_field="meraki",
... label="Site",
... multiple=False,
... ordering=("displayName",),
... limit_choices_to=lambda qs, instance, request: qs.filter(meraki__isnull=True)
... )
"""
def __init__(
self,
model: type[models.Model],
fk_field: str,
label: str | None = None,
help_text: str | None = None,
required: bool = False,
multiple: bool = False,
limit_choices_to: (
Callable[[models.QuerySet, Any, HttpRequest], models.QuerySet] | dict[str, Any] | None
) = None,
widget: forms.Widget | type[forms.Widget] | None = None,
ordering: Iterable[str] | None = None,
clean: Callable[[models.Model, Any, HttpRequest], None] | None = None,
permission: ReversePermissionPolicy | PermissionCallable | object | None = None,
permission_denied_message: str | None = None,
bulk: bool = False,
) -> None:
"""Initialize ReverseRelationConfig.
Args:
model (Type[models.Model]): Reverse-side model holding the FK.
fk_field (str): Name of the ForeignKey field on the reverse-side
model (the one specified in ``model``) that points back to the
current admin object.
label (Optional[str]): Optional field label. If omitted, a label is
derived from the reverse model's verbose name.
help_text (Optional[str]): Optional help text shown under the field.
required (bool): If True, selection is required (enforced by form).
multiple (bool): If True, use multi-select and sync many rows.
limit_choices_to (Optional[Callable|dict]): Callable
``(queryset, instance, request) -> queryset`` or a dict used for
``queryset.filter(**dict)`` to limit choices (e.g., unbound or
already-bound-to-current).
widget (Optional[forms.Widget|type[forms.Widget]]): Widget instance
or class to render the field. Defaults to Django's ``forms.Select`` for
single and ``FilteredSelectMultiple`` for multi.
ordering (Optional[Iterable[str]]): Optional ordering for the
resulting queryset.
clean (Optional[Callable[[models.Model, Any, HttpRequest], None]]):
Optional per-field validation hook invoked from the derived
``ModelForm.clean()``. Receives ``(instance, selection, request)``
and should raise ``forms.ValidationError`` to block submission
or return ``None`` for success.
permission (Optional[ReversePermissionPolicy | PermissionCallable | object]):
Optional per-field permission policy. Accepts callables,
objects with ``has_perm(...)`` or objects implementing
:class:`ReversePermissionPolicy`. When provided, the policy
determines whether the user can modify this field and may
expose ``permission_denied_message`` for feedback.
permission_denied_message (Optional[str]): Optional error message
to display on the field when permission checks fail during
form validation.
bulk (bool): When True, use Django's .update() method for
bind/unbind operations instead of individual model saves.
This bypasses model signals but provides better performance
for large datasets. Defaults to False for backward compatibility.
"""
object.__setattr__(self, "model", model)
object.__setattr__(self, "fk_field", fk_field)
object.__setattr__(self, "label", label)
object.__setattr__(self, "help_text", help_text)
object.__setattr__(self, "required", required)
object.__setattr__(self, "multiple", multiple)
object.__setattr__(self, "limit_choices_to", limit_choices_to)
object.__setattr__(self, "widget", widget)
object.__setattr__(self, "ordering", ordering)
object.__setattr__(self, "clean", clean)
object.__setattr__(self, "permission", permission)
object.__setattr__(self, "permission_denied_message", permission_denied_message)
object.__setattr__(self, "bulk", bulk)
model: type[models.Model]
fk_field: str
label: str | None = None
help_text: str | None = None
required: bool = False
multiple: bool = False
limit_choices_to: (
Callable[[models.QuerySet, Any, HttpRequest], models.QuerySet] | dict[str, Any] | None
) = None
widget: forms.Widget | type[forms.Widget] | None = None
ordering: Iterable[str] | None = None
clean: Callable[[models.Model, Any, HttpRequest], None] | None = None
permission: ReversePermissionPolicy | PermissionCallable | object | None = None
permission_denied_message: str | None = None
bulk: bool = False
[docs]
class ReversePermissionPolicy(Protocol):
"""Protocol for checking reverse relation modification permissions.
Policies may be provided as callable objects or as objects exposing a
``has_perm`` method. The mixin calls ``has_perm`` if present before falling
back to calling the object itself, so implementations can opt into either
style. Policy objects may provide ``permission_denied_message`` to customise
validation errors.
Example::
class StaffOnlyPolicy:
permission_denied_message = "Staff access required"
def __call__(self, request, obj, config, selection):
return getattr(request.user, "is_staff", False)
"""
def __call__(
self,
request: HttpRequest,
obj: models.Model | None,
config: ReverseRelationConfig,
selection: Any | None,
) -> bool:
"""Check if the user may modify the reverse relation for this field.
Args:
request (HttpRequest): Current HTTP request containing user information
and other request context.
obj (models.Model | None): The parent model instance being edited.
May be None for new instances or in certain contexts.
config (ReverseRelationConfig): Configuration object containing
field-specific settings and metadata.
selection (Any | None): Current selection value, if applicable.
The type depends on the specific field implementation.
Returns:
bool: True if the user is allowed to make changes to this reverse
relation, False otherwise.
Note:
When returning False, consider setting permission_denied_message
to provide helpful feedback to users about why access was denied.
"""
permission_denied_message: str | None = None
"""Optional error message to display on the field when permission checks fail during
form validation.
This message will be shown to users when __call__ returns False, helping them
understand why they cannot modify the reverse relation.
"""
[docs]
class ReverseRelationAdminMixin:
"""Mixin to expose reverse ForeignKey bindings on admin forms.
Add this mixin to a Django admin class and declare one or more virtual
fields in ``reverse_relations``. Each virtual field renders a form control
that operates on objects of ``ReverseRelationConfig.model`` by updating its
``fk_field`` to point at the current admin instance.
The mixin ensures:
- The virtual fields appear in ``fieldsets`` without causing Django to
raise unknown-field errors during form construction.
- Querysets are filtered per request/object via ``limit_choices_to`` and
can be ordered or customised with widgets.
- Initial selections reflect the current reverse bindings for the object
under edit.
- Permission gating (optional) can hide/disable fields at render-time and
block unauthorized selections during validation.
- On save, only authorized fields are persisted and the reverse
ForeignKey(s) are synchronized to match the submitted values
(unbinding anything deselected).
Attributes:
reverse_relations (dict[str, ReverseRelationConfig]):
Mapping of virtual field name to configuration. The keys here should
be used inside the admin's ``fieldsets`` like any normal field.
reverse_relations_atomic (bool):
When True (default), all reverse relation updates performed during a
form save are executed inside a single ``transaction.atomic()``
block. Within each configured field, unbinds are applied before
binds to reduce transient uniqueness conflicts. Any database error
will roll back the entire set of updates so no partial state is
persisted. Set to False to disable transactional behavior.
reverse_permissions_enabled (bool):
When True, require permission to modify reverse fields.
reverse_permission_policy (Optional[ReversePermissionPolicy | object]):
Optional global policy (callable or object with has_perm) used before the
default change_<model> check. Per-field config.permission still takes
precedence over this.
reverse_permission_mode (str):
Behavior when user lacks permission on the reverse model for a field:
- "disable": render field disabled (read-only) and ignore posted changes
- "hide": remove field from the form
Usage:
>>> class MyAdmin(ReverseRelationAdminMixin, ModelAdmin):
... reverse_relations = {
... "site_binding": ReverseRelationConfig(
... model=Site,
... fk_field="meraki",
... ordering=("displayName",),
... )
... }
"""
reverse_relations: dict[str, ReverseRelationConfig] = {}
"""Mapping of virtual field name to configuration."""
reverse_relations_atomic: bool = True
"""When True (default), all reverse relation updates performed during a
form save are executed inside a single ``transaction.atomic()``
block. Within each configured field, unbinds are applied before
binds to reduce transient uniqueness conflicts. Any database error
will roll back the entire set of updates so no partial state is
persisted. Set to False to disable transactional behavior."""
reverse_permissions_enabled: bool = False
"""When True, require permission to modify reverse fields."""
reverse_permission_policy: ReversePermissionPolicy | PermissionCallable | object | None = None
"""Optional global policy (callable or object with ``has_perm``) used before
the default ``change_<model>`` check. Per-field ``config.permission`` still
takes precedence over this."""
reverse_permission_mode: str = "disable"
"""Behavior when user lacks permission on the reverse model for a field:
- `"disable"`: render field disabled (read-only) and ignore posted changes
- `"hide"`: remove the field from the form"""
reverse_render_uses_field_policy: bool = False
"""If True, the render gate consults per-field/global policies via
:meth:`has_reverse_change_permission` (with ``selection=None``) instead of
the base permission check. This lets per-field policies affect visibility
and editability before any selection exists. Default False preserves the
global/base-only render behaviour."""
# no alias/back-compat: package did not ship previous flag
[docs]
def has_reverse_change_permission(
self,
request: HttpRequest,
obj: models.Model | None,
config: ReverseRelationConfig,
selection: Any = None,
) -> bool:
"""Check if the user may change the reverse model for this field.
By default, requires the global ``change`` permission on the reverse
model. Overrides evaluate in order of precedence: per-field policies on
``ReverseRelationConfig.permission``, then
``reverse_permission_policy`` on the admin, followed by this fallback
method. Override to enforce object-level checks or alternative
permission codenames.
Args:
request (HttpRequest): Current request (for ``user``).
obj (models.Model | None): The parent instance being edited.
config (ReverseRelationConfig): Field configuration.
selection (Any): Current selection, if applicable.
Returns:
bool: True if changes are allowed.
"""
# 1) Per-field policy supplied on the config
policy = getattr(config, "permission", None)
if policy is not None:
if hasattr(policy, "has_perm"):
return bool(policy.has_perm(request, obj, config, selection))
if callable(policy):
return bool(policy(request, obj, config, selection))
# 2) Global policy on the admin, if supplied
policy = getattr(self, "reverse_permission_policy", None)
if policy is not None:
if hasattr(policy, "has_perm"):
return bool(policy.has_perm(request, obj, config, selection))
if callable(policy):
return bool(policy(request, obj, config, selection))
# 3) Default global change permission on the reverse model
app_label = config.model._meta.app_label
model_name = config.model._meta.model_name
return bool(
getattr(request, "user", None)
and request.user.has_perm(f"{app_label}.change_{model_name}")
)
def _has_base_permission(
self,
request: HttpRequest,
obj: models.Model | None,
config: ReverseRelationConfig,
) -> bool:
"""Base permission for the render gate (no selection context).
The render gate decides whether a field is visible or disabled before
any selection is available. Selection-dependent policies are evaluated
later during form ``clean()``. When a global policy is provided it is
consulted here; otherwise the default ``change_<model>`` permission is
used. If ``request.user`` is missing or lacks ``has_perm`` the field is
rendered (admin view-level guards are assumed to apply).
"""
policy = getattr(self, "reverse_permission_policy", None)
if policy is not None:
if hasattr(policy, "has_perm"):
return bool(policy.has_perm(request, obj, config, None))
if callable(policy):
return bool(policy(request, obj, config, None))
user = getattr(request, "user", None)
if not hasattr(user, "has_perm"):
return True
app = config.model._meta.app_label
model = config.model._meta.model_name
return bool(user.has_perm(f"{app}.change_{model}"))
[docs]
def get_reverse_relations(self) -> dict[str, ReverseRelationConfig]:
"""Return the configured reverse relations for this admin.
Returns:
dict[str, ReverseRelationConfig]: Mapping of virtual field names to
their configuration.
"""
return self.reverse_relations
[docs]
def get_fields(self, request, obj=None): # type: ignore[override]
"""Ensure virtual reverse field names are part of the rendered fields.
When an admin does not declare ``fieldsets`` (and does not supply
``fields`` explicitly), Django renders all form fields returned by
``get_fields``. This override appends the virtual reverse field names so
templates include them. The base ``get_form`` implementation receives
the same list and our ``get_form`` override will strip virtual names
before building the base form to avoid unknown-field errors.
"""
base_fields = super().get_fields(request, obj)
relations = tuple(self.get_reverse_relations().keys())
if not relations:
return base_fields
merged = list(base_fields or [])
for name in relations:
if name not in merged:
merged.append(name)
return merged
[docs]
def save_model(self, request: HttpRequest, obj, form, change):
"""Save model and apply any deferred reverse relation updates.
This ensures reverse relations are synchronized even when the form save
was called with ``commit=False``.
Args:
request (HttpRequest): The current request.
obj (models.Model): The model instance being saved.
form (forms.ModelForm): The bound form.
change (bool): True if updating an existing object, False if adding.
"""
super().save_model(request, obj, form, change)
if hasattr(form, "_reverse_relation_data") and form._reverse_relation_data is not None:
self._apply_reverse_relations(obj, form._reverse_relation_data)
form._reverse_relation_data = None
def _build_reverse_field(
self,
config: ReverseRelationConfig,
instance,
request: HttpRequest,
defer_queryset: bool = False,
):
"""Construct a form field for the given reverse relation config.
Args:
config (ReverseRelationConfig): The reverse relation configuration.
instance (models.Model | None): The instance being edited, if any.
request (HttpRequest): The current request.
Returns:
forms.Field: A ``ModelChoiceField`` or ``ModelMultipleChoiceField``
configured with labels, help text, widget, and a queryset (empty if
``defer_queryset`` is True).
"""
if defer_queryset:
queryset = config.model._default_manager.none()
else:
queryset = self._get_reverse_queryset(config, instance, request)
label = config.label
if not label:
meta = config.model._meta
label = (
meta.verbose_name_plural.title() if config.multiple else meta.verbose_name.title()
)
widget = self._resolve_widget(config, label, config.multiple)
if config.multiple:
return forms.ModelMultipleChoiceField(
queryset=queryset,
required=config.required,
label=label,
help_text=config.help_text,
widget=widget,
)
field = forms.ModelChoiceField(
queryset=queryset,
required=config.required,
label=label,
help_text=config.help_text,
widget=widget,
)
field.empty_label = "---------"
return field
def _resolve_widget(self, config: ReverseRelationConfig, label: str, multiple: bool):
"""Resolve the widget to use for a reverse relation field.
If a widget instance or class is provided on the config, it is used. By
default, multi-select uses Django's ``FilteredSelectMultiple`` and
single-select uses Django's ``forms.Select``.
Args:
config (ReverseRelationConfig): The field configuration.
label (str): The computed field label (used by some widgets).
multiple (bool): Whether the field is multi-select.
Returns:
forms.Widget: The widget instance to use.
"""
widget = config.widget
if widget:
return widget() if isinstance(widget, type) else widget
if multiple:
return FilteredSelectMultiple(label, is_stacked=False)
return forms.Select()
def _get_reverse_queryset(self, config: ReverseRelationConfig, instance, request: HttpRequest):
"""Compute the queryset for a reverse relation field.
Applies ``limit_choices_to`` (callable or dict) and optional ordering.
This is called during form initialization and can depend on the current
object and request.
Args:
config (ReverseRelationConfig): The field configuration.
instance (models.Model | None): The instance being edited, if any.
request (HttpRequest): The current request.
Returns:
models.QuerySet: The filtered and ordered queryset.
"""
queryset = config.model._default_manager.all()
limiter = config.limit_choices_to
if callable(limiter):
queryset = limiter(queryset, instance, request)
elif limiter:
queryset = queryset.filter(**limiter)
if config.ordering:
queryset = queryset.order_by(*config.ordering)
return queryset
def _get_reverse_initial(self, config: ReverseRelationConfig, instance):
"""Compute the initial selection for a reverse relation field.
Args:
config (ReverseRelationConfig): The field configuration.
instance (models.Model | None): The instance being edited, if any.
Returns:
list[int] | int | None: For multi-select fields, returns a list of
primary keys. For single-select fields, returns the primary key of
the related object or ``None`` if there is no current binding.
"""
if not instance or not getattr(instance, "pk", None):
return [] if config.multiple else None
queryset = config.model._default_manager.filter(**{config.fk_field: instance})
if config.multiple:
return list(queryset.values_list("pk", flat=True))
related = queryset.first()
return related.pk if related else None
def _apply_bulk_unbind(self, config: ReverseRelationConfig, instance, exclude_pks: set):
"""Unbind multiple objects using .update() for performance.
Uses Django's .update() method to set the foreign key to None for objects
that should be unbound from the current instance. This bypasses model
signals but provides better performance for large datasets.
Args:
config (ReverseRelationConfig): The field configuration.
instance (models.Model): The saved model instance serving as the FK target.
exclude_pks (set): Set of primary keys to exclude from unbinding.
Raises:
forms.ValidationError: If database constraints prevent the unbind operation.
Exception: Any other database error during the bulk update.
"""
from django import forms
from django.db import IntegrityError
try:
# Build queryset for objects currently bound to this instance
queryset = config.model._default_manager.filter(**{config.fk_field: instance})
# Exclude objects that should remain bound (for multi-select scenarios)
if exclude_pks:
queryset = queryset.exclude(pk__in=exclude_pks)
# Perform bulk unbind using .update()
if queryset.exists():
queryset.update(**{config.fk_field: None})
except IntegrityError as e:
raise forms.ValidationError(
f"Bulk unbind operation failed for {config.model._meta.verbose_name}: {e}"
) from e
except Exception as e:
raise forms.ValidationError(
f"Unexpected error during bulk unbind operation: {e}"
) from e
def _apply_bulk_bind(self, config: ReverseRelationConfig, instance, target_objects):
"""Bind multiple objects using .update() for performance.
Uses Django's .update() method to set the foreign key to the target instance
for objects that should be bound. This bypasses model signals but provides
better performance for large datasets.
Args:
config (ReverseRelationConfig): The field configuration.
instance (models.Model): The saved model instance serving as the FK target.
target_objects (list): List of objects to bind to the instance.
Raises:
forms.ValidationError: If database constraints prevent the bind operation.
Exception: Any other database error during the bulk update.
"""
from django import forms
from django.db import IntegrityError
if not target_objects:
return
try:
# Get primary keys of objects that need to be bound
# Always include the selected primary keys. In bulk single-select flows,
# an earlier unbind uses .update() which does not refresh in-memory
# objects from the form queryset. Filtering out objects that "already"
# point at instance based on stale in-memory attributes would cause a
# silent unbind when submitting the same selection.
target_pks = [obj.pk for obj in target_objects]
# Perform bulk bind using .update() if there are objects to bind
if target_pks:
config.model._default_manager.filter(pk__in=target_pks).update(
**{config.fk_field: instance}
)
except IntegrityError as e:
raise forms.ValidationError(
f"Bulk bind operation failed for {config.model._meta.verbose_name}: {e}"
) from e
except Exception as e:
raise forms.ValidationError(f"Unexpected error during bulk bind operation: {e}") from e
def _apply_bulk_operations(self, config: ReverseRelationConfig, instance, selection):
"""Coordinate bulk unbind and bind operations for a reverse relation field.
Maintains the unbind-before-bind ordering to minimize transient constraint
violations. Handles both single-select and multi-select scenarios using
bulk .update() operations for better performance.
Args:
config (ReverseRelationConfig): The field configuration.
instance (models.Model): The saved model instance serving as the FK target.
selection: The submitted selection (object for single-select,
iterable of objects for multi-select).
Raises:
forms.ValidationError: If database constraints prevent the operations
or other errors occur during bulk updates.
"""
from django import forms
try:
if config.multiple:
# Multi-select scenario
selected = list(selection) if selection else []
selected_ids = {obj.pk for obj in selected}
# Step 1: Bulk unbind objects that are no longer selected
# (exclude the ones that should remain bound)
self._apply_bulk_unbind(config, instance, selected_ids)
# Step 2: Bulk bind newly selected objects
self._apply_bulk_bind(config, instance, selected)
else:
# Single-select scenario
target = selection
# Step 1: Bulk unbind all current relations
# For single-select, we unbind everything first
self._apply_bulk_unbind(config, instance, set())
# Step 2: Bulk bind the target (if provided)
if target:
self._apply_bulk_bind(config, instance, [target])
except forms.ValidationError:
# Re-raise validation errors as-is
raise
except Exception as e:
# Wrap unexpected errors in ValidationError with meaningful message
raise forms.ValidationError(
f"Bulk operation failed for {config.model._meta.verbose_name}: {e}"
) from e
def _apply_individual_operations(self, config: ReverseRelationConfig, instance, selection):
"""Apply bind/unbind operations using individual model saves.
This is the original behavior that triggers model signals (pre_save, post_save)
for each object. Used when config.bulk=False or for backward compatibility.
Args:
config (ReverseRelationConfig): The field configuration.
instance (models.Model): The saved model instance serving as the FK target.
selection: The submitted selection (object for single-select,
iterable of objects for multi-select).
"""
manager = config.model._default_manager
current = list(manager.filter(**{config.fk_field: instance}))
if config.multiple:
selected = list(selection) if selection else []
selected_ids = {obj.pk for obj in selected}
# Unbind removed relations first
for item in current:
if item.pk not in selected_ids:
setattr(item, config.fk_field, None)
item.save(update_fields=[config.fk_field])
# Then bind newly selected relations
for obj in selected:
if getattr(obj, config.fk_field) != instance:
setattr(obj, config.fk_field, instance)
obj.save(update_fields=[config.fk_field])
else:
target = selection
# Unbind all others first
for item in current:
if not target or item.pk != getattr(target, "pk", None):
setattr(item, config.fk_field, None)
item.save(update_fields=[config.fk_field])
# Then bind the target (if provided)
if target and getattr(target, config.fk_field) != instance:
setattr(target, config.fk_field, instance)
target.save(update_fields=[config.fk_field])
def _apply_reverse_relations(self, instance, payload: dict[str, Any]):
"""Persist authorized reverse-relation selections to the database.
Synchronizes the reverse-side ForeignKey(s) so they exactly match the
submitted form values. Callers pass a payload that has already been
filtered for permission (``save()`` only includes authorized fields).
Within each configured relation the method unbinds deselected rows
before binding new selections to minimize transient uniqueness
conflicts.
Args:
instance (models.Model): The saved model instance serving as the FK
target.
payload (dict[str, Any]): Mapping of virtual field name to submitted
selection (object or iterable of objects for multi-select).
Raises:
Exception: Any database or integrity error raised during updates is
not swallowed and will propagate to the admin. When
:attr:`reverse_relations_atomic` is True, the transaction will
be rolled back and no changes will be persisted.
Notes:
Updates occur inside a single ``transaction.atomic()`` block when
:attr:`reverse_relations_atomic` is enabled so either all reverse
relations are synchronized or none are.
"""
def _apply() -> None:
"""Process each configured reverse field. Routes to bulk operations
when config.bulk=True, individual operations when config.bulk=False."""
for field_name, config in self.get_reverse_relations().items():
if field_name not in payload:
# Skip unauthorized fields that were stripped from the payload. When the
# field name is absent we must not touch the existing bindings.
continue
selection = payload.get(field_name)
if config.bulk:
# Route to bulk operations for better performance
self._apply_bulk_operations(config, instance, selection)
else:
# Use individual saves (existing behavior)
self._apply_individual_operations(config, instance, selection)
if self.reverse_relations_atomic:
# Apply all updates as a single unit; on error, the entire set of
# operations is rolled back so that no partial state is persisted.
with transaction.atomic():
_apply()
else:
_apply()