https://github.com/tnware/django-admin-reversefields
https://github.com/tnware/django-admin-reversefields
Last synced: 5 months ago
JSON representation
- Host: GitHub
- URL: https://github.com/tnware/django-admin-reversefields
- Owner: tnware
- License: bsd-2-clause
- Created: 2025-09-21T22:44:27.000Z (9 months ago)
- Default Branch: main
- Last Pushed: 2025-09-22T22:58:41.000Z (9 months ago)
- Last Synced: 2025-09-29T02:02:36.250Z (9 months ago)
- Language: Python
- Size: 146 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Agents: AGENTS.md
Awesome Lists containing this project
README
# django-admin-reversefields
[](https://pypi.org/project/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
- 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)
---
## Install
```bash
pip install django-admin-reversefields
```
Supported: Django 4.2/5.0/5.1/5.2; Python 3.10–3.13.
---
## Quickstart
```python
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"`) 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”.
---
## Core concepts (tl;dr)
- Reverse fields are virtual `ModelChoiceField` / `ModelMultipleChoiceField` instances 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=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`.
---
## Permissions (optional)
Enable enforcement:
```python
class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
reverse_permissions_enabled = True
reverse_permission_mode = "disable" # or "hide"
```
- Precedence for allow/deny:
1) Per-field `ReverseRelationConfig.permission`
2) `reverse_permission_policy` (admin-wide)
3) Default `user.has_perm("app.change_model")` on the reverse model
- 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=False` on disabled reverse fields so forms won’t raise “This field is required.” when there is no initial value.
- "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
Import:
```python
from django_admin_reversefields.mixins import ReverseRelationAdminMixin, ReverseRelationConfig
```
`ReverseRelationConfig` (per virtual field):
- `model`: reverse-side `models.Model` that holds the ForeignKey to the admin model
- `fk_field`: name of that ForeignKey on `model`
- `label`, `help_text`: optional display strings
- `required`: enforce non-empty selection (default False)
- `multiple`: multi-select that syncs many rows (default False)
- `limit_choices_to`: callable `(qs, instance, request) -> qs` or `dict` passed to `.filter(**dict)`
- `widget`: widget instance or class; defaults to admin `Select`/`FilteredSelectMultiple`
- `ordering`: iterable for `.order_by()`
- `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:
- `reverse_relations`: mapping of virtual field name → config
- `reverse_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 policy
- `reverse_render_uses_field_policy`: use per-field/global policy at render time (selection=None)
---
## Recipes and docs
- [Quickstart](https://tnware.github.io/django-admin-reversefields/quickstart.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)
---
## Development
We use [`uv`](https://github.com/astral-sh/uv) for tooling.
- `uv sync` — install project + docs deps
- `uv run ruff check .` — lint
- `uv run django-admin test` or `uv run python manage.py test` — tests
- `uv run sphinx-build -b html docs docs/_build/html -W` — docs build
Release:
```bash
uv build
Twine upload dist/*
```