diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index de2a91c..9ff7569 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,95 +1,99 @@ -name: tests - -on: - push: - branches: [main] - paths-ignore: - - "README.md" - pull_request: - paths-ignore: - - "README.md" - branches: [main] - -jobs: - test: - name: Run Tests - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - # supporting Python 3.10-3.12 - - python-version: "3.10" - django-version: "4.2" - - python-version: "3.11" - django-version: "4.2" - - python-version: "3.12" - django-version: "4.2" - - python-version: "3.13" - django-version: "4.2" - - - python-version: "3.10" - django-version: "5.0" - - python-version: "3.11" - django-version: "5.0" - - python-version: "3.12" - django-version: "5.0" - - python-version: "3.13" - django-version: "5.0" - - - python-version: "3.10" - django-version: "5.1" - - python-version: "3.11" - django-version: "5.1" - - python-version: "3.12" - django-version: "5.1" - - python-version: "3.13" - django-version: "5.1" - - - python-version: "3.10" - django-version: "5.2" - - python-version: "3.11" - django-version: "5.2" - - python-version: "3.12" - django-version: "5.2" - - python-version: "3.13" - django-version: "5.2" - - steps: - - uses: actions/checkout@v4 - - - name: Install UV - uses: astral-sh/setup-uv@v5 - with: - version: "0.6.5" - python-version: ${{ matrix.python-version }} - enable-cache: true - - - name: Create venv and install dependencies - run: | - uv venv - uv pip install "django==${{ matrix.django-version }}" - uv sync --all-extras --dev - - - name: Run Tests - run: uv run python manage.py test -v 2 - - # lint: - # name: Lint Code - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - - # - name: Install UV - # uses: astral-sh/setup-uv@v5 - # with: - # version: "0.6.5" - # python-version: "3.12" - # enable-cache: true - - # - name: Create venv and install dependencies - # run: | - # uv venv - - # - name: Run Ruff - # run: uv run ruff check . +name: tests + +on: + push: + branches: [main] + paths-ignore: + - "*.md" + - "docs/**" + - ".github/release-drafter.yml" + pull_request: + branches: [main] + paths-ignore: + - "*.md" + - "docs/**" + - ".github/release-drafter.yml" + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # supporting Python 3.10-3.12 + - python-version: "3.10" + django-version: "4.2" + - python-version: "3.11" + django-version: "4.2" + - python-version: "3.12" + django-version: "4.2" + - python-version: "3.13" + django-version: "4.2" + + - python-version: "3.10" + django-version: "5.0" + - python-version: "3.11" + django-version: "5.0" + - python-version: "3.12" + django-version: "5.0" + - python-version: "3.13" + django-version: "5.0" + + - python-version: "3.10" + django-version: "5.1" + - python-version: "3.11" + django-version: "5.1" + - python-version: "3.12" + django-version: "5.1" + - python-version: "3.13" + django-version: "5.1" + + - python-version: "3.10" + django-version: "5.2" + - python-version: "3.11" + django-version: "5.2" + - python-version: "3.12" + django-version: "5.2" + - python-version: "3.13" + django-version: "5.2" + + steps: + - uses: actions/checkout@v4 + + - name: Install UV + uses: astral-sh/setup-uv@v5 + with: + version: "0.6.5" + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Create venv and install dependencies + run: | + uv venv + uv pip install "django==${{ matrix.django-version }}" + uv sync --all-extras --dev + + - name: Run Tests + run: uv run python manage.py test -v 2 + + # lint: + # name: Lint Code + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + + # - name: Install UV + # uses: astral-sh/setup-uv@v5 + # with: + # version: "0.6.5" + # python-version: "3.12" + # enable-cache: true + + # - name: Create venv and install dependencies + # run: | + # uv venv + + # - name: Run Ruff + # run: uv run ruff check . diff --git a/README.md b/README.md index 89db484..5545b7d 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,10 @@ Mixin knobs: ## 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) +- [Concepts & Architecture](https://tnware.github.io/django-admin-reversefields/concepts.html) +- [Configuration](https://tnware.github.io/django-admin-reversefields/configuration.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) --- diff --git a/docs/architecture.rst b/docs/architecture.rst deleted file mode 100644 index daa4ca5..0000000 --- a/docs/architecture.rst +++ /dev/null @@ -1,105 +0,0 @@ -Architecture -============ - -.. contents:: Page contents - :depth: 1 - :local: - -High-level components ---------------------- - -``ReverseRelationAdminMixin`` layers reverse-relation behaviour onto Django’s -``ModelAdmin`` lifecycle. Configuration happens declaratively on the admin -class; the mixin then wires dynamic form fields, validation, permissions, and -persistence logic around Django’s normal flow. - -* ``reverse_relations`` — declarative mapping of :term:`virtual field ` name to - :class:`~django_admin_reversefields.mixins.ReverseRelationConfig`. Each - configuration knows which reverse model to touch, which ForeignKey points - back to the admin object, whether the selection is multi-valued, and how to - scope the queryset. -* ``ReverseRelationConfig`` — per-field knobs for labels, widgets, queryset - :term:`limiters `, validation hooks, and permission :term:`policies `. -* ``reverse_relations_atomic`` — governs whether all reverse updates execute in - a single :func:`django.db.transaction.atomic` block. -* Permission hooks — optional policies that gate rendering and persistence. - -Form lifecycle --------------- - -1. **Field declaration** — the admin declares :term:`virtual field ` names in - ``reverse_relations`` and lists them in ``fieldsets`` so the Django admin - template renders them. -2. **Form construction** — :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_form` - strips the virtual names out of the base ``fields`` argument (to avoid - Django’s "unknown field" errors) and delegates to ``super()``. After the base - form class is produced, the mixin injects ``ModelChoiceField`` or - ``ModelMultipleChoiceField`` instances for each configured relation. Querysets - come from ``ReverseRelationConfig.limit_choices_to`` (callable or ``dict``) - plus optional ``ordering``. -3. **Initial data** — the derived form’s ``__init__`` resolves the queryset and - current selections for the object under edit. :term:`Virtual fields ` point at the - reverse model’s objects whose ForeignKey already references the parent - instance. -4. **Render gate** — if ``reverse_permissions_enabled`` is true, the form checks - permissions before rendering. By default this uses a base permission check, - but can be configured to use the full permission :term:`policy ` to allow - 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 --------------------------- - -* ``ReverseRelationConfig.clean`` hooks run during form ``clean()`` with - ``(instance, selection, request)``. Use this for business rules such as - capacity limits or forbidding unbinds. -* Permission evaluation happens twice: - - 1. During ``clean()`` — when a custom :term:`policy ` (per-field or global) denies a - specific selection, the field receives a validation error. Error messages - resolve using the precedence described in :doc:`permissions-guide`. - 2. During ``save()`` — unauthorized fields are excluded from the persistence - payload to guard against crafted POSTs. - -Persistence ------------ - -``ModelForm.save`` delegates to the base implementation and then synchronizes -reverse relations via -:meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin._apply_reverse_relations`. -For each configured field: - -* Multi-select fields compute the exact set of rows that should point at the - parent instance. Items removed from the selection are unbound (ForeignKey set - to ``None``) before new :term:`bindings ` are applied. -* Single-select fields unbind all rows except the chosen object, then bind the - target if it is not already pointing at the instance. - -When ``reverse_relations_atomic`` is ``True`` (the default) all configured -fields are synchronized inside a single transaction so either all :term:`bindings ` are -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 ------------------------ - -* Provide custom widgets by supplying ``ReverseRelationConfig.widget`` with a - widget instance or class (e.g., DAL/Unfold). -* Scope querysets dynamically with a callable ``limit_choices_to``. Callables - receive the current request and instance, allowing per-user filtering. -* Implement per-field ``permission`` :term:`policies ` or assign - ``reverse_permission_policy`` on the admin for global rules. :term:`Policies ` may be - callables or objects implementing ``has_perm``. -* Override :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.has_reverse_change_permission` - if you need to enforce different permission codenames (``add``/``delete``) or - object-level checks. diff --git a/docs/caveats.rst b/docs/caveats.rst index e3ad01a..c5b991c 100644 --- a/docs/caveats.rst +++ b/docs/caveats.rst @@ -3,8 +3,8 @@ Caveats This page highlights operational edge-cases and limitations that are easy to overlook while following the happy-path guides (:doc:`quickstart`, -:doc:`recipes`). Use it alongside :doc:`data-integrity` and -:doc:`permissions-guide` when planning production roll-outs. +:doc:`recipes`). Use it alongside :doc:`concepts` and +:doc:`configuration` when planning production roll-outs. .. contents:: Page contents :local: @@ -25,7 +25,7 @@ ForeignKey requirements .. seealso:: The persistence order (unbind before bind) is documented in - :doc:`data-integrity`. + :doc:`concepts`. Transactional behaviour ----------------------- @@ -55,10 +55,10 @@ Display customisation The default widgets mirror the Django admin look-and-feel. Override ``ReverseRelationConfig.widget`` to plug in custom widgets (e.g. Unfold, DAL) or see the :doc:`advanced` gallery for end-to-end examples. For request-aware -choice limiting, revisit :doc:`querysets-and-widgets`. +choice limiting, revisit :doc:`configuration`. For how fields are included in the form and when they are visible vs. disabled, -see :doc:`rendering`. +see :doc:`configuration`. Permission interplay -------------------- @@ -70,14 +70,14 @@ Permission interplay 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 + (``"disable"`` or ``"hide"``). Refer back to :doc:`configuration` 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. + See :doc:`configuration` for the end-to-end visibility and editability flow. One-to-one specifics -------------------- diff --git a/docs/concepts.rst b/docs/concepts.rst new file mode 100644 index 0000000..45f2c92 --- /dev/null +++ b/docs/concepts.rst @@ -0,0 +1,236 @@ +Concepts & Architecture +======================= + +This chapter covers how +:class:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin` works: +the :term:`virtual fields ` it injects, how the admin form +lifecycle is extended, how :term:`bindings ` are synchronised, and +what transaction guarantees apply. For complete admin examples, see +:ref:`recipe-single-binding` and :ref:`recipe-multiple-binding`. + +.. contents:: Page contents + :depth: 2 + :local: + +What the mixin injects +---------------------- + +:class:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin` introduces *virtual* form fields that proxy the +reverse side of ForeignKey and OneToOne relationships. Those fields are declared +in :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_relations`. +If your admin declares ``fieldsets`` or ``fields``, you must include the virtual +names there so the Django template renders them. If your admin declares neither +``fieldsets`` nor ``fields``, Django renders all form fields by default and the +injected :term:`virtual fields ` will appear automatically. This +works because the mixin's :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_fields` +appends the virtual names for you and :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_form` +injects the corresponding form fields dynamically. + +.. note:: + + If you override :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_fields` + without calling ``super()``, or you hard-code ``fields``/``fieldsets`` and + omit the virtual names, the admin template will not render the virtual + fields. The form still contains them (the mixin injects them), but the layout + derives from ``get_fields``/``fieldsets``. + +During :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_form` +the mixin removes those virtual names from the base form +(avoiding "unknown field" errors), creates +:class:`~django.forms.ModelChoiceField`/:class:`~django.forms.ModelMultipleChoiceField` +instances on the fly, and wires up any labels, help texts, or widgets defined on +:class:`~django_admin_reversefields.mixins.ReverseRelationConfig`. + +Request-aware querysets +----------------------- + +Every :term:`virtual field ` resolves its queryset and initial selection for the current +request. :attr:`~django_admin_reversefields.mixins.ReverseRelationConfig.limit_choices_to` can be a callable that +receives ``(queryset, instance, request)`` and returns a scoped queryset. This +lets you present only the objects a user is allowed to bind while still showing +items already attached to the instance under edit. + +Single vs. multiple selections +------------------------------ + +:attr:`~django_admin_reversefields.mixins.ReverseRelationConfig.multiple` determines whether the field captures a +single object or a synchronised set: + +* ``multiple=False`` (default) — behaves like a dropdown. The chosen object's + ForeignKey is set to the admin object, and any other rows pointing at it are + unbound. +* ``multiple=True`` — represents the *entire* desired set. After form submission + the mixin unbinds rows not in the selection before :term:`binding ` the chosen ones to + the instance. The resulting database state matches the submitted list exactly. + +.. warning:: + Single-select unbinds all other objects pointing at the instance. Ensure the + reverse ForeignKey is ``null=True``. If the relation must never be empty, + set ``required=True`` on the :term:`virtual field ` to prevent :term:`unbinding ` from raising + an ``IntegrityError``. + +Bulk operations and performance +------------------------------- + +:class:`~django_admin_reversefields.mixins.ReverseRelationConfig` supports an optional ``bulk`` parameter +that changes how bind/unbind operations are performed: + +* ``bulk=False`` (default) — uses individual model saves, triggering all Django + model signals (``pre_save``, ``post_save``, etc.) for each affected object. +* ``bulk=True`` — uses Django's ``.update()`` method for better performance but + bypasses model signals entirely. + +**Performance considerations:** + +.. list-table:: + :header-rows: 1 + :widths: 30 35 35 + + * - Aspect + - Individual Saves (``bulk=False``) + - Bulk Operations (``bulk=True``) + * - Database round-trips + - One per object + - One per operation type + * - Model signals + - ✅ Triggered normally + - ❌ Bypassed entirely + * - Performance + - Slower with large datasets + - ✅ Significantly faster + * - Error granularity + - ✅ Per-object errors + - Batch-level errors only + * - Memory usage + - Higher (object instantiation) + - ✅ Lower (queryset operations) + +**Best practices for bulk mode:** + +* Enable bulk mode when managing hundreds or thousands of relationships +* Ensure your application doesn't depend on model signals for the reverse model +* Use bulk mode consistently across related configurations for optimal performance +* Consider the trade-off between performance and signal-based functionality + +.. warning:: + + Bulk operations bypass Django's model signal system. If your application relies + on ``pre_save``, ``post_save``, ``pre_delete``, or other model signals for the + reverse relationship model, do not enable bulk mode. + +.. _concepts-form-lifecycle: + +Form lifecycle +-------------- + +``ReverseRelationAdminMixin`` layers reverse-relation behaviour onto Django's +``ModelAdmin`` lifecycle. Configuration happens declaratively on the admin +class; the mixin then wires dynamic form fields, validation, permissions, and +persistence logic around Django's normal flow. + +1. **Field declaration** — the admin declares :term:`virtual field ` names in + ``reverse_relations`` and lists them in ``fieldsets`` so the Django admin + template renders them. +2. **Form construction** — :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_form` + strips the virtual names out of the base ``fields`` argument (to avoid + Django's "unknown field" errors) and delegates to ``super()``. After the base + form class is produced, the mixin injects ``ModelChoiceField`` or + ``ModelMultipleChoiceField`` instances for each configured relation. Querysets + come from ``ReverseRelationConfig.limit_choices_to`` (callable or ``dict``) + plus optional ``ordering``. +3. **Initial data** — the derived form's ``__init__`` resolves the queryset and + current selections for the object under edit. :term:`Virtual fields ` point at the + reverse model's objects whose ForeignKey already references the parent + instance. +4. **Render gate** — if ``reverse_permissions_enabled`` is true, the form checks + permissions before rendering. By default this uses a base permission check, + but can be configured to use the full permission :term:`policy ` to allow + per-field visibility. Fields become hidden or disabled based on + ``reverse_permission_mode``. See :ref:`configuration-visibility` for details. + +Validation and permissions +-------------------------- + +* ``ReverseRelationConfig.clean`` hooks run during form ``clean()`` with + ``(instance, selection, request)``. Use this for business rules such as + capacity limits or forbidding unbinds. +* Permission evaluation happens twice: + + 1. During ``clean()`` — when a custom :term:`policy ` (per-field or global) denies a + specific selection, the field receives a validation error. Error messages + resolve using the precedence described in :ref:`configuration-permissions`. + 2. During ``save()`` — unauthorized fields are excluded from the persistence + payload to guard against crafted POSTs. + +.. _concepts-persistence: + +Persistence +----------- + +``ModelForm.save`` delegates to the base implementation and then synchronizes +reverse relations via +:meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin._apply_reverse_relations`. +For each configured field: + +* Multi-select fields compute the exact set of rows that should point at the + parent instance. Items removed from the selection are unbound (ForeignKey set + to ``None``) before new :term:`bindings ` are applied. +* Single-select fields unbind all rows except the chosen object, then bind the + target if it is not already pointing at the instance. + +When ``reverse_relations_atomic`` is ``True`` (the default) all configured +fields are synchronized inside a single transaction so either all :term:`bindings ` are +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. + +.. _concepts-data-integrity: + +Data integrity & transactions +----------------------------- + +.. note:: + By default, the mixin wraps the entire update in a single + :func:`django.db.transaction.atomic` block (``reverse_relations_atomic=True``). + If any virtual field raises an error, the whole operation rolls back. You can + opt out with ``reverse_relations_atomic=False`` if you prefer to persist + changes field-by-field. + +**Unbind before bind:** + +Within each field's update, the mixin unbinds rows before :term:`binding ` new ones. +This avoids transient uniqueness errors on ``OneToOneField`` or ForeignKeys +with ``unique=True``, as the old relation is cleared before the new one is +claimed. + +**One-to-one specifics:** + +* Treat ``OneToOneField`` relations as single-select fields (``multiple=False``). +* If the reverse relation is non-nullable, you must configure ``required=True`` + or make the underlying database field nullable. Otherwise, :term:`unbinding ` an object + would raise an ``IntegrityError``. + +Extensibility checklist +----------------------- + +* Provide custom widgets by supplying ``ReverseRelationConfig.widget`` with a + widget instance or class (e.g., DAL/Unfold). +* Scope querysets dynamically with a callable ``limit_choices_to``. Callables + receive the current request and instance, allowing per-user filtering. +* Implement per-field ``permission`` :term:`policies ` or assign + ``reverse_permission_policy`` on the admin for global rules. :term:`Policies ` may be + callables or objects implementing ``has_perm``. +* Override :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.has_reverse_change_permission` + if you need to enforce different permission codenames (``add``/``delete``) or + object-level checks. + +.. seealso:: + - :doc:`configuration` — Permissions, visibility, querysets, and widgets. + - :doc:`recipes` — End-to-end admin setups. + - :doc:`caveats` — Operational edge-cases. diff --git a/docs/permissions-guide.rst b/docs/configuration.rst similarity index 58% rename from docs/permissions-guide.rst rename to docs/configuration.rst index 2d50540..5b60c3a 100644 --- a/docs/permissions-guide.rst +++ b/docs/configuration.rst @@ -1,218 +1,338 @@ -Permissions -=========== - -Use this guide to enforce Django permissions on reverse relations and craft -custom :term:`policies ` beyond the default ``change`` checks. Ready-to-run snippets live -in :ref:`recipe-permissions`. - -.. contents:: Page contents - :depth: 1 - :local: - -Permission modes ----------------- - -Set ``reverse_permissions_enabled=True`` to have the mixin evaluate access -before rendering or saving a :term:`virtual field `. ``reverse_permission_mode`` controls -how denied access surfaces: - -* ``"disable"`` (default) — render the field disabled and ignore submitted - changes. The mixin also sets ``required=False`` on 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 :term:`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) :term:`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)``. :term:`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 :term:`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 -:meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.has_reverse_change_permission` -or per field via ``ReverseRelationConfig.permission``. You may provide :term:`policies ` -in three ergonomic shapes with identical semantics (return ``True`` to allow, -``False`` to deny). - -.. tab-set:: - - .. tab-item:: Callable - - A callable with the signature ``(request, obj, config, selection) -> bool`` - is ideal for tiny, stateless predicates. - - .. tab-item:: Policy object - - A :term:`Policy ` object implementing ``__call__`` and optionally - ``permission_denied_message`` is useful for bundling state or reusable - messages. - - .. tab-item:: has_perm object - - 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 :term:`policies ` override the global method when provided. - -Evaluation flow ---------------- - -Permission checks run at three points: - -1. **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_policy`` is ``True``, it consults the full - per-field or global :term:`policy ` (with ``selection=None``). -2. **Validation gate** — once a selection exists, the per-field :term:`policy ` runs (if - defined) and otherwise the global :term:`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. -3. **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 :term:`policy ` denies a selection, the field error message resolves in this -order: - -1. ``ReverseRelationConfig.permission_denied_message`` -2. ``permission.permission_denied_message`` on the per-field :term:`policy ` object -3. ``reverse_permission_policy.permission_denied_message`` on the global :term:`policy ` - object -4. Default ``"You do not have permission to choose this value."`` - -.. list-table:: Permission denied message precedence - :header-rows: 1 - - * - Source - - Example attribute path - - Precedence - * - Field override - - ``config.permission_denied_message`` - - 1 (highest) - * - Per-field :term:`policy ` object - - ``config.permission.permission_denied_message`` - - 2 - * - Global :term:`policy ` object - - ``admin.reverse_permission_policy.permission_denied_message`` - - 3 - * - Built-in default - - ``"You do not have permission to choose this value."`` - - 4 (lowest) - -Visualising the flow --------------------- - -.. mermaid:: - - 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 ----------------- - -.. tab-set:: - - .. tab-item:: Function (simple rule) - - .. code-block:: python - - def only_staff(request, obj, config, selection): - return getattr(request.user, "is_staff", False) - - ReverseRelationConfig(..., permission=only_staff) - - .. tab-item:: Policy object (stateful + message) - - .. code-block:: python - - 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)) - - .. tab-item:: has_perm object (adapter) - - .. code-block:: python - - 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()) - -.. seealso:: - - :doc:`rendering` — Visibility vs editability and the render gate. - - :doc:`recipes` — End-to-end permission setups in context. - - :doc:`core-concepts` — Where permissions fit in the lifecycle. +Configuration +============= + +This guide covers how to control the behaviour of your +:term:`virtual fields `: visibility, editability, queryset +scoping, widgets, and permission enforcement. For how these fit into the form +lifecycle, see :doc:`concepts`. For ready-to-run examples, see :doc:`recipes`. + +.. contents:: Page contents + :depth: 2 + :local: + +.. _configuration-visibility: + +Rendering & visibility +---------------------- + +How virtual fields appear +^^^^^^^^^^^^^^^^^^^^^^^^^ + +- ``get_fields``: The mixin appends the virtual names declared in + :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_relations` to the + list of fields returned by ``ModelAdmin.get_fields`` so templates know about them. +- ``get_form``: It strips those names from the base ``fields`` passed to the form factory + (to avoid unknown-field errors), then injects real + :class:`~django.forms.ModelChoiceField` / + :class:`~django.forms.ModelMultipleChoiceField` instances with the configured + label, help text, widget, queryset, and initial selection. + +Layout rules (fields vs. fieldsets) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- If you declare ``fieldsets`` or ``fields``, 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_fields`` without calling ``super()``, or you return a + hard-coded ``fields`` list that omits the virtual names, the form will still + contain the injected fields but the template will not render them. + +Visibility vs. editability +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_permissions_enabled` +is True the mixin runs a render gate for each virtual field: + +- Mode is controlled by :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_permission_mode`: + + - ``"hide"`` removes the field from the form (no input is rendered). + - ``"disable"`` keeps it visible but sets ``disabled=True`` and relaxes ``required`` to avoid + spurious "This field is required." errors. +- By default, the render gate consults a base/global permission (roughly + ``change_``). To let per-field/global policies decide visibility + up front, set + :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_render_uses_field_policy` + to True. In that mode + :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.has_reverse_change_permission` + is called with ``selection=None``. + +Troubleshooting +^^^^^^^^^^^^^^^ + +- **Field does not render** — Ensure its virtual name appears in ``fieldsets`` or + ``fields`` (or do not declare either), and avoid overriding ``get_fields`` + without calling ``super()``. +- **Field renders but is read-only** — Check ``reverse_permissions_enabled`` + + ``reverse_permission_mode``, and whether ``reverse_render_uses_field_policy`` + is True with a policy that denies access for the current user. + +.. _configuration-querysets: + +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 :term:`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 :class:`~django_admin_reversefields.mixins.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 :term:`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. + +.. seealso:: + :doc:`advanced` — Complete DAL/Unfold widget examples. + +.. _configuration-permissions: + +Permissions +----------- + +Use this section to enforce Django permissions on reverse relations and craft +custom :term:`policies ` beyond the default ``change`` checks. Ready-to-run snippets live +in :ref:`recipe-permissions`. + +Permission modes +^^^^^^^^^^^^^^^^ + +Set ``reverse_permissions_enabled=True`` to have the mixin evaluate access +before rendering or saving a :term:`virtual field `. ``reverse_permission_mode`` controls +how denied access surfaces: + +* ``"disable"`` (default) — render the field disabled and ignore submitted + changes. The mixin also sets ``required=False`` on 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 :term:`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) :term:`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)``. :term:`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 :term:`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 +:meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.has_reverse_change_permission` +or per field via ``ReverseRelationConfig.permission``. You may provide :term:`policies ` +in three ergonomic shapes with identical semantics (return ``True`` to allow, +``False`` to deny). + +.. tab-set:: + + .. tab-item:: Callable + + A callable with the signature ``(request, obj, config, selection) -> bool`` + is ideal for tiny, stateless predicates. + + .. tab-item:: Policy object + + A :term:`Policy ` object implementing ``__call__`` and optionally + ``permission_denied_message`` is useful for bundling state or reusable + messages. + + .. tab-item:: has_perm object + + 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 :term:`policies ` override the global method when provided. + +Evaluation flow +^^^^^^^^^^^^^^^ + +Permission checks run at three points: + +1. **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_policy`` is ``True``, it consults the full + per-field or global :term:`policy ` (with ``selection=None``). +2. **Validation gate** — once a selection exists, the per-field :term:`policy ` runs (if + defined) and otherwise the global :term:`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. +3. **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 :term:`policy ` denies a selection, the field error message resolves in this +order: + +1. ``ReverseRelationConfig.permission_denied_message`` +2. ``permission.permission_denied_message`` on the per-field :term:`policy ` object +3. ``reverse_permission_policy.permission_denied_message`` on the global :term:`policy ` + object +4. Default ``"You do not have permission to choose this value."`` + +.. list-table:: Permission denied message precedence + :header-rows: 1 + + * - Source + - Example attribute path + - Precedence + * - Field override + - ``config.permission_denied_message`` + - 1 (highest) + * - Per-field :term:`policy ` object + - ``config.permission.permission_denied_message`` + - 2 + * - Global :term:`policy ` object + - ``admin.reverse_permission_policy.permission_denied_message`` + - 3 + * - Built-in default + - ``"You do not have permission to choose this value."`` + - 4 (lowest) + +Visualising the flow +^^^^^^^^^^^^^^^^^^^^ + +.. mermaid:: + + 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 +^^^^^^^^^^^^^^^^ + +.. tab-set:: + + .. tab-item:: Function (simple rule) + + .. code-block:: python + + def only_staff(request, obj, config, selection): + return getattr(request.user, "is_staff", False) + + ReverseRelationConfig(..., permission=only_staff) + + .. tab-item:: Policy object (stateful + message) + + .. code-block:: python + + 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)) + + .. tab-item:: has_perm object (adapter) + + .. code-block:: python + + 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()) + +.. seealso:: + - :doc:`recipes` — End-to-end permission setups in context. + - :doc:`concepts` — Where permissions fit in the lifecycle. diff --git a/docs/core-concepts.rst b/docs/core-concepts.rst deleted file mode 100644 index 472fd5d..0000000 --- a/docs/core-concepts.rst +++ /dev/null @@ -1,137 +0,0 @@ -Core Concepts -============= - -This chapter introduces the :term:`virtual fields ` that -:class:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin` adds to a -:class:`~django.contrib.admin.ModelAdmin` and how the mixin keeps single and -multi-select :term:`bindings ` in sync. For complete admin examples, see -:ref:`recipe-single-binding` and :ref:`recipe-multiple-binding`. - -.. contents:: Page contents - :depth: 1 - :local: - -What the mixin injects ----------------------- - -:class:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin` introduces *virtual* form fields that proxy the -reverse side of ForeignKey and OneToOne relationships. Those fields are declared -in :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_relations`. -If your admin declares ``fieldsets`` or ``fields``, you must include the virtual -names there so the Django template renders them. If your admin declares neither -``fieldsets`` nor ``fields``, Django renders all form fields by default and the -injected :term:`virtual fields ` will appear automatically. This -works because the mixin's :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_fields` -appends the virtual names for you and :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_form` -injects the corresponding form fields dynamically. - -.. note:: - - If you override :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_fields` - without calling ``super()``, or you hard-code ``fields``/``fieldsets`` and - omit the virtual names, the admin template will not render the virtual - fields. The form still contains them (the mixin injects them), but the layout - derives from ``get_fields``/``fieldsets``. - -During :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.get_form` -the mixin removes those virtual names from the base form -(avoiding "unknown field" errors), creates -:class:`~django.forms.ModelChoiceField`/:class:`~django.forms.ModelMultipleChoiceField` -instances on the fly, and wires up any labels, help texts, or widgets defined on -:class:`~django_admin_reversefields.mixins.ReverseRelationConfig`. - -.. seealso:: - For visibility/editability at render time and layout rules, see :doc:`rendering`. - -Request-aware querysets ------------------------ - -Every :term:`virtual field ` resolves its queryset and initial selection for the current -request. :attr:`~django_admin_reversefields.mixins.ReverseRelationConfig.limit_choices_to` can be a callable that -receives ``(queryset, instance, request)`` and returns a scoped queryset. This -lets you present only the objects a user is allowed to bind while still showing -items already attached to the instance under edit. - -Single vs. multiple selections ------------------------------- - -:attr:`~django_admin_reversefields.mixins.ReverseRelationConfig.multiple` determines whether the field captures a -single object or a synchronised set: - -* ``multiple=False`` (default) — behaves like a dropdown. The chosen object's - ForeignKey is set to the admin object, and any other rows pointing at it are - unbound. -* ``multiple=True`` — represents the *entire* desired set. After form submission - the mixin unbinds rows not in the selection before :term:`binding ` the chosen ones to - the instance. The resulting database state matches the submitted list exactly. - -.. warning:: - Single-select unbinds all other objects pointing at the instance. Ensure the - reverse ForeignKey is ``null=True``. If the relation must never be empty, - set ``required=True`` on the :term:`virtual field ` to prevent :term:`unbinding ` from raising - an ``IntegrityError``. - -Bulk operations and performance -------------------------------- - -:class:`~django_admin_reversefields.mixins.ReverseRelationConfig` supports an optional ``bulk`` parameter -that changes how bind/unbind operations are performed: - -* ``bulk=False`` (default) — uses individual model saves, triggering all Django - model signals (``pre_save``, ``post_save``, etc.) for each affected object. -* ``bulk=True`` — uses Django's ``.update()`` method for better performance but - bypasses model signals entirely. - -**Performance considerations:** - -.. list-table:: - :header-rows: 1 - :widths: 30 35 35 - - * - Aspect - - Individual Saves (``bulk=False``) - - Bulk Operations (``bulk=True``) - * - Database round-trips - - One per object - - One per operation type - * - Model signals - - ✅ Triggered normally - - ❌ Bypassed entirely - * - Performance - - Slower with large datasets - - ✅ Significantly faster - * - Error granularity - - ✅ Per-object errors - - Batch-level errors only - * - Memory usage - - Higher (object instantiation) - - ✅ Lower (queryset operations) - -**Best practices for bulk mode:** - -* Enable bulk mode when managing hundreds or thousands of relationships -* Ensure your application doesn't depend on model signals for the reverse model -* Use bulk mode consistently across related configurations for optimal performance -* Consider the trade-off between performance and signal-based functionality - -.. warning:: - - Bulk operations bypass Django's model signal system. If your application relies - on ``pre_save``, ``post_save``, ``pre_delete``, or other model signals for the - reverse relationship model, do not enable bulk mode. - -Permissions interaction ------------------------ - -When permission enforcement is enabled and a reverse field is rendered in -``disable`` mode, the field becomes read-only and its POSTed value (if any) is -ignored. To avoid spurious validation errors, the mixin also forces -``required=False`` on such disabled fields — this prevents Django from raising -"This field is required." when there is no initial value and the browser omits -the disabled input from the submission. - -.. seealso:: - - :doc:`architecture` dives deeper into how the mixin hooks into the admin form - lifecycle. - - :doc:`data-integrity` explains the transaction model that keeps :term:`bindings ` - consistent across fields. diff --git a/docs/data-integrity.rst b/docs/data-integrity.rst deleted file mode 100644 index b5469cf..0000000 --- a/docs/data-integrity.rst +++ /dev/null @@ -1,44 +0,0 @@ -Data Integrity & Transactions -============================= - -.. contents:: Page contents - :depth: 1 - :local: - -This guide explains how reverse :term:`bindings ` are persisted, what the default -transaction guarantees look like, and how to treat reverse ``OneToOneField`` -relationships safely. For reference implementations, check -:ref:`recipe-single-binding` and :ref:`recipe-multiple-binding`. - -Transactional saves -------------------- - -.. note:: - By default, the mixin wraps the entire update in a single - :func:`django.db.transaction.atomic` block (``reverse_relations_atomic=True``). - If any virtual field raises an error, the whole operation rolls back. You can - opt out with ``reverse_relations_atomic=False`` if you prefer to persist - changes field-by-field. - -Unbind before bind ------------------- - -.. note:: - Within each field's update, the mixin unbinds rows before :term:`binding ` new ones. - This avoids transient uniqueness errors on ``OneToOneField`` or ForeignKeys - with ``unique=True``, as the old relation is cleared before the new one is - claimed. - -One-to-one specifics --------------------- - -.. note:: - Treat ``OneToOneField`` relations as single-select fields (``multiple=False``). - If the reverse relation is non-nullable, you must configure ``required=True`` - or make the underlying database field nullable. Otherwise, :term:`unbinding ` an object - would raise an ``IntegrityError``. - -.. seealso:: - - :doc:`recipes` — End-to-end single and multiple binding examples. - - :doc:`core-concepts` — Lifecycle summary and transaction overview. - - :doc:`caveats` — Edge cases and operational considerations. diff --git a/docs/development.rst b/docs/development.rst index 40ee392..6cf4b25 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -1,43 +1,75 @@ -Development -=========== - -Install (uv) ------------- - -.. code-block:: bash - - git clone https://github.com/tnware/django-admin-reversefields - cd django-admin-reversefields - uv venv .venv - # Windows PowerShell - . .\.venv\Scripts\Activate.ps1 - # macOS/Linux - # source .venv/bin/activate - - uv pip install -e . - uv pip install -r docs/requirements.txt - -Build docs ----------- - -.. code-block:: bash - - uv run sphinx-build -b html docs docs/_build/html -W - -Run tests ---------- - -.. code-block:: bash - - uv run python manage.py test -v 2 - -Release -------- - -Version is defined in ``django_admin_reversefields/__init__.py``. - -.. code-block:: bash - - uv build - # or: python -m build - twine upload dist/* +Development +=========== + +Install (uv) +------------ + +.. code-block:: bash + + git clone https://github.com/tnware/django-admin-reversefields + cd django-admin-reversefields + uv venv .venv + # Windows PowerShell + . .\.venv\Scripts\Activate.ps1 + # macOS/Linux + # source .venv/bin/activate + + uv pip install -e . + uv pip install -r docs/requirements.txt + +Build docs +---------- + +.. code-block:: bash + + uv run sphinx-build -b html docs docs/_build/html -W + +Run tests +--------- + +.. code-block:: bash + + uv run python manage.py test -v 2 + +Interactive dev server +---------------------- + +The ``tests/`` app doubles as a runnable Django instance with realistic models +and admin configs that use the mixin. This is useful for manual smoke-testing +and visual inspection of widgets, permissions, and field rendering. + +.. code-block:: bash + + # Use a file-backed database (tests default to :memory:) + export DJANGO_DB_NAME=db.sqlite3 # Linux/macOS + # $env:DJANGO_DB_NAME="db.sqlite3" # Windows PowerShell + + uv run python manage.py migrate + uv run python manage.py seed # creates sample data + admin/admin superuser + uv run python manage.py runserver + +Visit ``http://localhost:8000/admin/`` and log in with ``admin`` / ``admin``. + +The ``seed`` command creates companies, departments, projects, employees, and +company settings — a mix of bound and unbound objects so you can immediately +test binding and unbinding. It is idempotent and skips if data already exists. + +The test models registered in the admin include: + +- **Company** — single-select ``departments`` and multi-select ``projects`` + virtual fields +- **Department** — single-select ``employees`` virtual field + +Edit a company to see the mixin in action. The ``db.sqlite3`` file is +git-ignored. + +Release +------- + +Version is defined in ``pyproject.toml``. + +.. code-block:: bash + + uv build + # or: python -m build + twine upload dist/* diff --git a/docs/glossary.rst b/docs/glossary.rst index e43b411..5f727d4 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -1,33 +1,59 @@ -Glossary -======== - -.. glossary:: - - Virtual Field - A form field dynamically injected into a :class:`~django.contrib.admin.ModelAdmin` - by :class:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin`. It does - not exist on the model itself but is used to manage the reverse relationship. - See :doc:`core-concepts` for a walkthrough of how these form controls are - created and synchronised. - - Binding - The action of associating a reverse object with the current admin object by - setting the :class:`~django.db.models.ForeignKey` on the reverse object to - point to the current one. The transaction ordering is covered in - :doc:`data-integrity`. - - Unbinding - The action of disassociating a reverse object from the current admin object, - typically by setting its ForeignKey to ``NULL``. Review the safeguards in - :doc:`caveats` when unbinding non-nullable relations. - - Limiter - A callable or dictionary provided in - :class:`~django_admin_reversefields.mixins.ReverseRelationConfig` that filters the - queryset for a virtual field, controlling which objects are available for - selection. See :doc:`querysets-and-widgets` for implementation strategies. - - Policy - A callable or object that implements permission checks for a virtual field. It - determines whether a user has the authority to view, edit, or make specific - selections. The evaluation flow is detailed in :doc:`permissions-guide`. +Glossary +======== + +.. glossary:: + + Virtual Field + A form field dynamically injected into a :class:`~django.contrib.admin.ModelAdmin` + by :class:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin`. It does + not exist on the model itself but is used to manage the reverse relationship. + See :doc:`concepts` for a walkthrough of how these form controls are + created and synchronised. + + Binding + The action of associating a reverse object with the current admin object by + setting the :class:`~django.db.models.ForeignKey` on the reverse object to + point to the current one. The transaction ordering is covered in + :doc:`concepts`. + + Unbinding + The action of disassociating a reverse object from the current admin object, + typically by setting its ForeignKey to ``NULL``. Review the safeguards in + :doc:`caveats` when unbinding non-nullable relations. + + Limiter + A callable or dictionary provided in + :class:`~django_admin_reversefields.mixins.ReverseRelationConfig` that filters the + queryset for a virtual field, controlling which objects are available for + selection. See :doc:`configuration` for implementation strategies. + + Policy + A callable or object that implements permission checks for a virtual field. It + determines whether a user has the authority to view, edit, or make specific + selections. The evaluation flow is detailed in :doc:`configuration`. + + Render Gate + The first of three permission checkpoints. Runs during form ``__init__`` + (before templates render) to decide whether a :term:`virtual field ` is + visible, disabled, or hidden. By default it consults a base permission; + enable ``reverse_render_uses_field_policy`` to use per-field + :term:`policies `. See :ref:`configuration-permissions`. + + Validation Gate + The second permission checkpoint. Runs during form ``clean()`` once a + selection exists. If a custom :term:`policy ` denies the selection, + a field error is attached. See :ref:`configuration-permissions`. + + Persistence Gate + The third and final permission checkpoint. Runs during form ``save()`` to + filter the update payload so that unauthorized :term:`virtual fields ` + are excluded — even if a crafted POST included them. See + :ref:`configuration-permissions`. + + Bulk Mode + An optional per-field setting (``bulk=True`` on + :class:`~django_admin_reversefields.mixins.ReverseRelationConfig`) that + uses Django's ``.update()`` for :term:`binding ` and + :term:`unbinding ` instead of individual model saves. Faster + for large datasets but bypasses model signals (``pre_save``, + ``post_save``). See :ref:`concepts-data-integrity`. diff --git a/docs/index.rst b/docs/index.rst index 0a22554..9908382 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,17 +32,13 @@ Contents :caption: How it works :maxdepth: 1 - core-concepts - architecture + concepts .. toctree:: :caption: Configuring behavior :maxdepth: 1 - data-integrity - rendering - querysets-and-widgets - permissions-guide + configuration advanced .. toctree:: diff --git a/docs/querysets-and-widgets.rst b/docs/querysets-and-widgets.rst deleted file mode 100644 index 0b3f53d..0000000 --- a/docs/querysets-and-widgets.rst +++ /dev/null @@ -1,60 +0,0 @@ -Querysets & Widgets -=================== - -.. contents:: Page contents - :depth: 1 - :local: - -Learn how to control which objects appear in each :term:`virtual field ` and how to tune -its presentation in the admin. Practical examples live in -:ref:`recipe-single-binding` and :ref:`recipe-multiple-binding`. - -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 :term:`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 :class:`~django_admin_reversefields.mixins.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 :term:`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. - -.. seealso:: - - :doc:`advanced` — Complete DAL/unfold widget examples. - - :doc:`recipes` — End-to-end admin setups using limiters and widgets. - - :doc:`rendering` — How fields appear and are included in the form. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 0dbb699..0d93fe1 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -11,7 +11,7 @@ few steps. .. seealso:: - Explore :doc:`core-concepts` for the lifecycle of the injected + Explore :doc:`concepts` for the lifecycle of the injected :term:`virtual fields ` and dive into :doc:`recipes` for end-to-end examples, including permissions and validation hooks. @@ -173,7 +173,7 @@ What happens on save? #. On ``form.save()``, the mixin applies transactional updates—unbind first, then bind—to keep the database consistent. When ``bulk=True``, operations use ``.update()`` for better performance. For details, see - :doc:`data-integrity`. + :doc:`concepts`. Next steps @@ -183,7 +183,7 @@ Next steps - Follow the :ref:`recipe-single-binding` and :ref:`recipe-multiple-binding` walkthroughs for complete admin setups. -- Enforce permissions with :doc:`permissions-guide` and +- Enforce permissions with :doc:`configuration` and :ref:`recipe-permissions`. - Consult the :doc:`api` reference when you need to override lifecycle methods such as :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.has_reverse_change_permission`. diff --git a/docs/recipes.rst b/docs/recipes.rst index aa32968..80d27b4 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -114,7 +114,7 @@ Permissions on reverse fields :open: .. seealso:: - For a deep dive into the permission system, see the :doc:`permissions-guide`. + For a deep dive into the permission system, see the :doc:`configuration`. Require ``change`` permission on the reverse model, with disabled field mode: diff --git a/docs/rendering.rst b/docs/rendering.rst deleted file mode 100644 index 1618a07..0000000 --- a/docs/rendering.rst +++ /dev/null @@ -1,84 +0,0 @@ -Rendering & Visibility -====================== - -This guide explains how the mixin gets your virtual fields onto the page, how to -control whether they show up, and when they are editable vs. read-only. It ties -the lifecycle together so you can reason about layout and permissions in one place. - -.. contents:: Page contents - :depth: 1 - :local: - -How virtual fields appear -------------------------- - -- ``get_fields``: The mixin appends the virtual names declared in - :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_relations` to the - list of fields returned by ``ModelAdmin.get_fields`` so templates know about them. -- ``get_form``: It strips those names from the base ``fields`` passed to the form factory - (to avoid unknown-field errors), then injects real - :class:`~django.forms.ModelChoiceField` / - :class:`~django.forms.ModelMultipleChoiceField` instances with the configured - label, help text, widget, queryset, and initial selection. - -Layout rules (fields vs. fieldsets) ------------------------------------ - -- If you declare ``fieldsets`` or ``fields``, 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_fields`` without calling ``super()``, or you return a - hard-coded ``fields`` list that omits the virtual names, the form will still - contain the injected fields but the template will not render them. - -Visibility vs. editability --------------------------- - -When :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_permissions_enabled` -is True the mixin runs a render gate for each virtual field: - -- Mode is controlled by :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_permission_mode`: - - - ``"hide"`` removes the field from the form (no input is rendered). - - ``"disable"`` keeps it visible but sets ``disabled=True`` and relaxes ``required`` to avoid - spurious "This field is required." errors. -- By default, the render gate consults a base/global permission (roughly - ``change_``). To let per-field/global policies decide visibility - up front, set - :attr:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.reverse_render_uses_field_policy` - to True. In that mode - :meth:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin.has_reverse_change_permission` - is called with ``selection=None``. - -Validation and persistence nuances ----------------------------------- - -- Validation errors for permission denials are attached only when a custom - policy (per-field or global) participates. Base-only denials rely on the UI - gating above. -- Hidden/disabled fields are ignored during save. The mixin filters the payload - of reverse fields during ``save()`` so crafted POSTs cannot change a hidden or - disabled field. - -Troubleshooting ---------------- - -- Field does not render - - - Ensure its virtual name appears in ``fieldsets`` or ``fields`` (or do not declare either), - and avoid overriding ``get_fields`` without calling ``super()``. - -- Field renders but is read-only - - - Check ``reverse_permissions_enabled`` + ``reverse_permission_mode``, and whether - ``reverse_render_uses_field_policy`` is True with a policy that denies access for the - current user. - -.. seealso:: - - :doc:`permissions-guide` — Modes (hide/disable), render-time policies, message - precedence. - - :doc:`querysets-and-widgets` — Limiting choices and customizing widgets. - - :doc:`recipes` — End-to-end examples. - - :doc:`core-concepts` — Lifecycle summary. diff --git a/tests/admin.py b/tests/admin.py index cc271aa..4e49288 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -26,23 +26,31 @@ class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): # Step 1: Declare reverse_relations dict keyed by virtual field name reverse_relations = { - # Single department selection - following minimal example pattern + # Multi-select: manage which departments belong to this company "departments": ReverseRelationConfig( model=Department, fk_field="company", + multiple=True, ), - # Multiple projects - using multiple=True from configuration highlights + # Multi-select: manage which projects belong to this company "projects": ReverseRelationConfig( model=Project, fk_field="company", multiple=True, ), + # Single-select: bind one CompanySettings instance (OneToOne) + "settings": ReverseRelationConfig( + model=CompanySettings, + fk_field="company", + multiple=False, + ), } # Step 2: Include virtual field names in fieldsets as instructed fieldsets = ( ("Company Information", {"fields": ("name", "founded_year")}), - ("Related Items", {"fields": ("departments", "projects")}), + ("Departments & Projects", {"fields": ("departments", "projects")}), + ("Settings", {"fields": ("settings",)}), ) @@ -56,10 +64,11 @@ class DepartmentAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): # Following the same pattern: dict keyed by virtual field name reverse_relations = { - # Single employee selection for department head + # Multi-select: manage all employees assigned to this department "employees": ReverseRelationConfig( model=Employee, fk_field="department", + multiple=True, ), } diff --git a/tests/management/__init__.py b/tests/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/management/commands/__init__.py b/tests/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/management/commands/seed.py b/tests/management/commands/seed.py new file mode 100644 index 0000000..f7f6b55 --- /dev/null +++ b/tests/management/commands/seed.py @@ -0,0 +1,56 @@ +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand + +from tests.models import Company, CompanySettings, Department, Employee, Project + + +class Command(BaseCommand): + help = "Seed the database with sample data for interactive testing." + + def handle(self, *args, **options): + if Company.objects.exists(): + self.stdout.write(self.style.WARNING("Data already exists — skipping seed.")) + return + + # Companies + acme = Company.objects.create(name="Acme Corp", founded_year=2010) + globex = Company.objects.create(name="Globex Inc", founded_year=2015) + Company.objects.create(name="Initech", founded_year=2020) + + # Departments — mix of bound and unbound + Department.objects.create(name="Engineering", company=acme) + Department.objects.create(name="Marketing") + Department.objects.create(name="Sales") + dept_hr = Department.objects.create(name="HR", company=globex) + Department.objects.create(name="Finance") + + # Projects — mix of bound and unbound + Project.objects.create(name="Project Alpha", company=acme) + Project.objects.create(name="Project Beta") + Project.objects.create(name="Project Gamma") + Project.objects.create(name="Project Delta", company=globex) + Project.objects.create(name="Project Epsilon") + + # Employees — mix of assigned and unassigned + Employee.objects.create(name="Alice", email="alice@test.com", department=dept_hr) + Employee.objects.create(name="Bob", email="bob@test.com") + Employee.objects.create(name="Charlie", email="charlie@test.com") + + # CompanySettings — one bound, one unbound + CompanySettings.objects.create(company=acme, timezone="US/Eastern", allow_remote_work=True) + CompanySettings.objects.create(timezone="Europe/London", fiscal_year_start=4) + + # Superuser + if not User.objects.filter(username="admin").exists(): + User.objects.create_superuser("admin", "admin@test.com", "admin") + self.stdout.write(" Superuser created: admin / admin") + + self.stdout.write( + self.style.SUCCESS( + f"Seeded: {Company.objects.count()} companies, " + f"{Department.objects.count()} departments, " + f"{Project.objects.count()} projects, " + f"{Employee.objects.count()} employees, " + f"{CompanySettings.objects.count()} company settings" + ) + ) diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py new file mode 100644 index 0000000..0aa4aed --- /dev/null +++ b/tests/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 5.2.6 on 2026-03-05 14:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Company', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('founded_year', models.IntegerField(blank=True, null=True)), + ], + options={ + 'verbose_name_plural': 'companies', + }, + ), + migrations.CreateModel( + name='CompanySettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timezone', models.CharField(default='UTC', max_length=50)), + ('fiscal_year_start', models.IntegerField(default=1)), + ('allow_remote_work', models.BooleanField(default=False)), + ('company', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='tests.company')), + ], + options={ + 'verbose_name_plural': 'company settings', + }, + ), + migrations.CreateModel( + name='Department', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('budget', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='departments', to='tests.company')), + ], + ), + migrations.CreateModel( + name='Employee', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('email', models.EmailField(max_length=254, unique=True)), + ('hire_date', models.DateField(blank=True, null=True)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employees', to='tests.department')), + ], + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('start_date', models.DateField(blank=True, null=True)), + ('end_date', models.DateField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='tests.company')), + ], + ), + migrations.CreateModel( + name='Assignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(max_length=50)), + ('hours_allocated', models.IntegerField(default=40)), + ('start_date', models.DateField(blank=True, null=True)), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='tests.employee')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='tests.project')), + ], + options={ + 'unique_together': {('employee', 'project')}, + }, + ), + ] diff --git a/tests/migrations/__init__.py b/tests/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models.py b/tests/models.py index 731e065..631c765 100644 --- a/tests/models.py +++ b/tests/models.py @@ -119,4 +119,5 @@ class Meta: verbose_name_plural = "company settings" def __str__(self) -> str: # pragma: no cover - repr helper for admin/tests - return f"Settings for {self.company.name}" + company_name = self.company.name if self.company else "Unassigned" + return f"Settings for {company_name}" diff --git a/tests/settings.py b/tests/settings.py index 924b06f..e60c693 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,50 +1,52 @@ -SECRET_KEY = "test-secret-key" -DEBUG = True -USE_TZ = True - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", - } -} - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "django_admin_reversefields", - "tests", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", -] - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ] - }, - } -] - -ROOT_URLCONF = "tests.urls" -STATIC_URL = "/static/" -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +import os + +SECRET_KEY = "test-secret-key" +DEBUG = True +USE_TZ = True + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.environ.get("DJANGO_DB_NAME", ":memory:"), + } +} + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_admin_reversefields", + "tests", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +ROOT_URLCONF = "tests.urls" +STATIC_URL = "/static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"