diff --git a/.github/workflows/docs_deploy.yml b/.github/workflows/docs_deploy.yml index 6a87e18..6da63a9 100644 --- a/.github/workflows/docs_deploy.yml +++ b/.github/workflows/docs_deploy.yml @@ -1,10 +1,8 @@ name: docs on: - push: - branches: [main] - paths: - - "docs/**" + release: + types: [published] workflow_dispatch: permissions: diff --git a/docs/development.rst b/docs/development.rst index 6cf4b25..a779745 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -56,9 +56,15 @@ 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 +- **Company** — multi-select ``departments`` and ``projects`` virtual fields, + plus single-select ``settings`` +- **Department** — multi-select ``employees`` virtual field + +The interactive admin examples use an ``unbound_or_current`` queryset pattern +for reverse fields so users can choose objects that are either unassigned or +already assigned to the object being edited. +Those reverse fields also include ``help_text`` in the admin UI to explain why +some options are intentionally filtered out. Edit a company to see the mixin in action. The ``db.sqlite3`` file is git-ignored. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 0d93fe1..693d0f9 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -58,9 +58,8 @@ mixin and declaring at least one reverse relation: .. literalinclude:: ../tests/admin.py :language: python - :lines: 16-46 - :caption: Minimal admin exposing two reverse bindings - :emphasize-lines: 13-31 + :lines: 17-91 + :caption: Minimal admin exposing reverse bindings with qualifying filters 1. ``reverse_relations`` is a ``dict`` keyed by virtual field name. 2. Each :class:`~django_admin_reversefields.mixins.ReverseRelationConfig` @@ -90,6 +89,7 @@ instance. from django.db.models import Q def unbound_or_current(queryset, instance, request): + """Offer unassigned rows plus rows already bound to this company.""" if instance and instance.pk: return queryset.filter(Q(company__isnull=True) | Q(company=instance)) return queryset.filter(company__isnull=True) @@ -100,6 +100,10 @@ instance. model=Department, fk_field="company", limit_choices_to=unbound_or_current, + help_text=( + "Choices are limited to departments that are unassigned or " + "already assigned to this company." + ), ) } @@ -108,6 +112,9 @@ instance. - ``limit_choices_to`` accepts either a callable ``(queryset, instance, request) -> queryset`` or a ``dict`` that is passed to :meth:`~django.db.models.query.QuerySet.filter`. + - Add short docstrings to limiter helpers and pair them with + ``help_text`` on the virtual field so users understand why some rows are + not selectable. - ``multiple=True`` switches a field to a :class:`~django.forms.ModelMultipleChoiceField` and synchronises the entire set on save. diff --git a/docs/recipes.rst b/docs/recipes.rst index 80d27b4..fc4d5cb 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -17,6 +17,7 @@ Single binding (Company ↔ Department) from django_admin_reversefields.mixins import ReverseRelationAdminMixin, ReverseRelationConfig def unbound_or_current(queryset, instance, request): + """Offer unassigned rows plus rows already bound to this company.""" if instance and instance.pk: return queryset.filter(Q(company__isnull=True) | Q(company=instance)) return queryset.filter(company__isnull=True) @@ -28,6 +29,10 @@ Single binding (Company ↔ Department) model=Department, fk_field="company", limit_choices_to=unbound_or_current, + help_text=( + "Choices are limited to departments that are unassigned or " + "already assigned to this company." + ), # Add bulk=True for better performance with large datasets # bulk=True, # Uncomment if you don't need model signals ) diff --git a/tests/admin.py b/tests/admin.py index 4e49288..2cab1db 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -4,6 +4,7 @@ # Required imports from quickstart guide from django.contrib import admin +from django.db.models import Q from django_admin_reversefields.mixins import ( ReverseRelationAdminMixin, @@ -13,6 +14,40 @@ from .models import Assignment, Company, CompanySettings, Department, Employee, Project +def unbound_or_current_company(queryset, instance, _request): + """Return company-scoped choices for reverse FK bindings. + + Args: + queryset: Base queryset for reverse-side objects. + instance: Company currently edited in the admin form. + _request: Active admin request (unused). + + Returns: + Filtered queryset containing objects that are either unbound or already + bound to the current company. + """ + if instance and instance.pk: + return queryset.filter(Q(company__isnull=True) | Q(company=instance)) + return queryset.filter(company__isnull=True) + + +def unbound_or_current_department(queryset, instance, _request): + """Return department-scoped choices for reverse FK bindings. + + Args: + queryset: Base queryset for reverse-side objects. + instance: Department currently edited in the admin form. + _request: Active admin request (unused). + + Returns: + Filtered queryset containing objects that are either unbound or already + bound to the current department. + """ + if instance and instance.pk: + return queryset.filter(Q(department__isnull=True) | Q(department=instance)) + return queryset.filter(department__isnull=True) + + @admin.register(Company) class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): """ @@ -31,12 +66,22 @@ class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): model=Department, fk_field="company", multiple=True, + limit_choices_to=unbound_or_current_company, + help_text=( + "Choices are limited to departments that are unassigned or already " + "assigned to this company." + ), ), # Multi-select: manage which projects belong to this company "projects": ReverseRelationConfig( model=Project, fk_field="company", multiple=True, + limit_choices_to=unbound_or_current_company, + help_text=( + "Choices are limited to projects that are unassigned or already " + "assigned to this company." + ), ), # Single-select: bind one CompanySettings instance (OneToOne) "settings": ReverseRelationConfig( @@ -69,6 +114,11 @@ class DepartmentAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): model=Employee, fk_field="department", multiple=True, + limit_choices_to=unbound_or_current_department, + help_text=( + "Choices are limited to employees who are unassigned or already " + "assigned to this department." + ), ), }