Configuration¶
This guide covers how to control the behaviour of your virtual fields: visibility, editability, queryset scoping, widgets, and permission enforcement. For how these fit into the form lifecycle, see Concepts & Architecture. For ready-to-run examples, see Recipes.
Rendering & visibility¶
How virtual fields appear¶
get_fields: The mixin appends the virtual names declared inreverse_relationsto the list of fields returned byModelAdmin.get_fieldsso templates know about them.get_form: It strips those names from the basefieldspassed to the form factory (to avoid unknown-field errors), then injects realModelChoiceField/ModelMultipleChoiceFieldinstances with the configured label, help text, widget, queryset, and initial selection.
Layout rules (fields vs. fieldsets)¶
If you declare
fieldsetsorfields, you must include the virtual names there (e.g."department_binding") or the admin template will not render them.If neither is declared, Django renders all form fields by default and the injected virtual fields appear automatically.
If you override
get_fieldswithout callingsuper(), or you return a hard-codedfieldslist that omits the virtual names, the form will still contain the injected fields but the template will not render them.
Visibility vs. editability¶
When reverse_permissions_enabled
is True the mixin runs a render gate for each virtual field:
Mode is controlled by
reverse_permission_mode:"hide"removes the field from the form (no input is rendered)."disable"keeps it visible but setsdisabled=Trueand relaxesrequiredto avoid spurious “This field is required.” errors.
By default, the render gate consults a base/global permission (roughly
change_<reverse_model>). To let per-field/global policies decide visibility up front, setreverse_render_uses_field_policyto True. In that modehas_reverse_change_permission()is called withselection=None.
Troubleshooting¶
Field does not render — Ensure its virtual name appears in
fieldsetsorfields(or do not declare either), and avoid overridingget_fieldswithout callingsuper().Field renders but is read-only — Check
reverse_permissions_enabled+reverse_permission_mode, and whetherreverse_render_uses_field_policyis True with a policy that denies access for the current user.
Querysets & widgets¶
Scoping choices¶
ReverseRelationConfig.limit_choices_to accepts either a dict (mirroring
Django’s ModelAdmin behaviour) or a callable of the form
(queryset, instance, request) -> queryset. Callables let you combine
request-aware filtering with inclusion of already-related rows, ensuring users
can keep existing bindings even when a global filter would hide them.
Caution
Static dict vs callable
A static dict filter cannot “reach back” to include objects already bound
to the current instance unless they also match the dict. If you need the
common pattern “unbound or currently bound”, prefer a callable, for example:
from django.db.models import Q
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)
Empty querysets¶
When the limiter produces an empty queryset, the field renders with no choices.
Form submissions with an empty selection remain valid unless you set
required=True on the ReverseRelationConfig.
Ordering selections¶
Apply ReverseRelationConfig.ordering to control how the queryset is sorted
before rendering. The tuple mirrors Django’s QuerySet.order_by parameters and
runs after limit_choices_to so you can safely rely on any additional filters
applied there.
Custom widgets¶
Every virtual field can override the default widget via
ReverseRelationConfig.widget. Supply either a widget instance or a widget
class. This works for stock Django form widgets as well as third-party options
such as Unfold Select2 or Django Autocomplete Light.
See also
Advanced — Complete DAL/Unfold widget examples.
Permissions¶
Use this section to enforce Django permissions on reverse relations and craft
custom policies beyond the default change checks. Ready-to-run snippets live
in Permissions on reverse fields.
Permission modes¶
Set reverse_permissions_enabled=True to have the mixin evaluate access
before rendering or saving a virtual field. reverse_permission_mode controls
how denied access surfaces:
"disable"(default) — render the field disabled and ignore submitted changes. The mixin also setsrequired=Falseon disabled reverse fields so that forms do not raise “This field is required.” when no initial value is present and the browser omits the disabled input from POST data."hide"— omit the field entirely.
Note
hide removes the input, so any required=True on the virtual field
does not apply. Use disable if you need the field to remain visible/read-only
while relaxing required semantics.
Use standard admin view guards if you want to block the whole page instead of a single field.
Render-time policies¶
By default, the render gate only consults a base/global permission to decide
visibility and editability. To let per-field (or global) policies influence
visibility at render time, set the class flag
reverse_render_uses_field_policy = True. In this mode the render gate calls
has_reverse_change_permission(request, obj, config, selection=None). Policies
must therefore handle selection=None sensibly.
Example:
class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
reverse_permissions_enabled = True
reverse_render_uses_field_policy = True
reverse_relations = {
"department_binding": ReverseRelationConfig(
model=Department,
fk_field="company",
multiple=False,
permission=lambda request, obj, config, selection: getattr(request.user, "is_staff", False),
)
}
The per-field policy above is evaluated during render with selection=None. If it
returns False, the field will be hidden or disabled according to
reverse_permission_mode.
Custom policies¶
Permissions can be supplied globally via
has_reverse_change_permission()
or per field via ReverseRelationConfig.permission. You may provide policies
in three ergonomic shapes with identical semantics (return True to allow,
False to deny).
A callable with the signature (request, obj, config, selection) -> bool
is ideal for tiny, stateless predicates.
A Policy object implementing __call__ and optionally
permission_denied_message is useful for bundling state or reusable
messages.
An object exposing has_perm(request, obj, config, selection) -> bool
is useful when adapting existing helpers. The permission_denied_message
attribute is honoured if present.
Per-field policies override the global method when provided.
Evaluation flow¶
Permission checks run at three points:
Render gate — decides whether a field is hidden or disabled before templates render. By default, it checks a base permission. If
reverse_render_uses_field_policyisTrue, it consults the full per-field or global policy (withselection=None).Validation gate — once a selection exists, the per-field policy runs (if defined) and otherwise the global policy is invoked. Denials raise a field error using the precedence below. If no custom policy is configured at all (neither per-field nor global), the mixin does not attach validation errors for base permission denials; instead, the UI gating (
hide/disable) applies, and hidden/disabled inputs are ignored on save.Persistence gate — as a safety net, the mixin excludes unauthorized fields from the update payload so crafted POSTs cannot persist changes. This includes hidden and disabled reverse fields.
Error message precedence¶
When a policy denies a selection, the field error message resolves in this order:
ReverseRelationConfig.permission_denied_messagepermission.permission_denied_messageon the per-field policy objectreverse_permission_policy.permission_denied_messageon the global policy objectDefault
"You do not have permission to choose this value."
Source |
Example attribute path |
Precedence |
|---|---|---|
Field override |
|
1 (highest) |
Per-field policy object |
|
2 |
Global policy object |
|
3 |
Built-in default |
|
4 (lowest) |
Visualising the flow¶
flowchart TD
subgraph Render
A[Start] --> B{reverse_permissions_enabled?};
B -- No --> RenderNormal[Render normally];
B -- Yes --> RenderGate{Render gate};
RenderGate --> CheckPolicyMode{reverse_render_uses_field_policy?};
CheckPolicyMode -- Yes --> CheckFieldPolicy{has_reverse_change_permission?};
CheckPolicyMode -- No --> CheckBasePerms{_has_base_permission?};
CheckFieldPolicy -- Deny --> SetVisibility;
CheckBasePerms -- Deny --> SetVisibility{Mode?};
SetVisibility -- hide --> Hide[Hide field];
SetVisibility -- disable --> Disable[Disable field];
CheckFieldPolicy -- Allow --> Visible[Visible/editable];
CheckBasePerms -- Allow --> Visible;
end
subgraph Validate and Persist
RenderNormal --> Clean;
Hide --> Clean;
Disable --> Clean;
Visible --> Clean[clean: run cfg.clean if present];
Clean --> Validate{has_reverse_change_permission?};
Validate -- Deny --> AddError[Add field error];
Validate -- Allow --> OK;
AddError --> Persist;
OK --> Persist[Persistence gate];
Persist --> FilterPayload[Build payload only for allowed fields];
FilterPayload --> Save[save: transaction.atomic; unbind before bind];
end
Minimal examples¶
def only_staff(request, obj, config, selection):
return getattr(request.user, "is_staff", False)
ReverseRelationConfig(..., permission=only_staff)
class OrgPolicy:
def __init__(self, org_id: int):
self.org_id = org_id
permission_denied_message = "You lack access to this organization."
def __call__(self, request, obj, config, selection):
return getattr(request.user, "org_id", None) == self.org_id
ReverseRelationConfig(..., permission=OrgPolicy(org_id=42))
class CanBindAdapter:
permission_denied_message = "Not allowed to bind this item."
def has_perm(self, request, obj, config, selection):
# delegate to some legacy checker
return legacy_can_bind(request.user, selection)
ReverseRelationConfig(..., permission=CanBindAdapter())
See also
Recipes — End-to-end permission setups in context.
Concepts & Architecture — Where permissions fit in the lifecycle.