diff --git a/.gitignore b/.gitignore index e5ebd79..355f336 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ target/ .vagrant/ .cursor/* .kiro/* +docs/_autosummary/ diff --git a/README.md b/README.md index 672bb10..89db484 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # django-admin-reversefields +[![PyPI - Downloads](https://img.shields.io/pypi/dw/django-admin-reversefields)](https://pypi.org/project/django-admin-reversefields/) +[![PyPI - Version](https://img.shields.io/pypi/v/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 @@ -15,7 +18,7 @@ Manage reverse ForeignKey/OneToOne bindings directly from a parent model’s Dja pip install django-admin-reversefields ``` -Supported: Django 4.2/5.0/5.1; Python 3.10–3.13. +Supported: Django 4.2/5.0/5.1/5.2; Python 3.10–3.13. --- @@ -29,29 +32,38 @@ from django_admin_reversefields.mixins import ( ReverseRelationAdminMixin, ReverseRelationConfig, ) +from .models import Company, Department, Project -def only_unbound_or_current(qs, instance, request): +def unbound_or_current(qs, instance, request): if instance and instance.pk: - return qs.filter(Q(service__isnull=True) | Q(service=instance)) - return qs.filter(service__isnull=True) + return qs.filter(Q(company__isnull=True) | Q(company=instance)) + return qs.filter(company__isnull=True) -@admin.register(Service) -class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): +@admin.register(Company) +class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - limit_choices_to=only_unbound_or_current, - ) + # 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 = (("Binding", {"fields": ("site_binding",)}),) + fieldsets = (("Relations", {"fields": ("department_binding", "assigned_projects")}),) ``` -- Include the virtual field name (e.g. `"site_binding"`) in `fieldsets` so Django renders it. -- Limiters run per request/object; use them to include unbound items plus the current binding. +- 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”. --- @@ -64,6 +76,8 @@ class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - 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`. --- @@ -88,6 +102,8 @@ class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - "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 @@ -111,6 +127,7 @@ from django_admin_reversefields.mixins import ReverseRelationAdminMixin, Reverse - `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: @@ -126,11 +143,12 @@ Mixin knobs: ## Recipes and docs - [Quickstart](https://tnware.github.io/django-admin-reversefields/quickstart.html) -- [Concepts](https://tnware.github.io/django-admin-reversefields/concepts.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) --- diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst new file mode 100644 index 0000000..4c39a4a --- /dev/null +++ b/docs/_templates/autosummary/class.rst @@ -0,0 +1,9 @@ +{{ objname }} +{{ '=' * objname|length }} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ fullname }} + :members: + :show-inheritance: + diff --git a/docs/_templates/autosummary/class_admin.rst b/docs/_templates/autosummary/class_admin.rst new file mode 100644 index 0000000..3c7b7ff --- /dev/null +++ b/docs/_templates/autosummary/class_admin.rst @@ -0,0 +1,10 @@ +{{ objname }} +{{ '=' * objname|length }} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ fullname }} + :members: + :show-inheritance: + :exclude-members: reverse_relations, reverse_relations_atomic, reverse_permissions_enabled, reverse_permission_policy, reverse_permission_mode + diff --git a/docs/advanced.rst b/docs/advanced.rst index f05b78d..35308ef 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -1,6 +1,12 @@ Advanced ======== +Advanced configuration examples and third-party widget integrations. + +.. contents:: Page contents + :depth: 1 + :local: + .. seealso:: The :ref:`recipe-ajax-widget` provides a complete, end-to-end recipe for integrating third-party AJAX widgets. @@ -23,37 +29,37 @@ Widgets from django.contrib import admin from dal import autocomplete from django.db.models import Q - from myapp.models import MerakiNet, Site + from myapp.models import Company, Department from django_admin_reversefields.mixins import ReverseRelationAdminMixin, ReverseRelationConfig - def meraki_network_site_queryset(queryset, instance, request): - """Allow binding to sites that are unbound or already tied to ``instance``.""" + def company_department_queryset(queryset, instance, request): + """Allow binding to departments that are unbound or already tied to ``instance``.""" if instance and instance.pk: - return queryset.filter(Q(meraki__isnull=True) | Q(meraki=instance)) - return queryset.filter(meraki__isnull=True) + return queryset.filter(Q(company__isnull=True) | Q(company=instance)) + return queryset.filter(company__isnull=True) - class SiteAutocomplete(autocomplete.Select2QuerySetView): + class DepartmentAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): - qs = Site.objects.all() + qs = Department.objects.all() term = (self.q or "").strip() if term: - qs = qs.filter(displayName__icontains=term) - return qs.order_by("displayName") + qs = qs.filter(name__icontains=term) + return qs.order_by("name") - @admin.register(MerakiNet) - class MerakiNetAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="meraki", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", widget=autocomplete.ModelSelect2( - url="site-autocomplete", attrs={"data-minimum-input-length": 2} + url="department-autocomplete", attrs={"data-minimum-input-length": 2} ), - limit_choices_to=meraki_network_site_queryset, + limit_choices_to=company_department_queryset, ) } @@ -76,12 +82,12 @@ Widgets from unfold.widgets import UnfoldAdminSelectWidget - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", widget=UnfoldAdminSelectWidget(), ) } @@ -94,12 +100,12 @@ Widgets from unfold.widgets import UnfoldAdminSelect2Widget - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", widget=UnfoldAdminSelect2Widget(), ) } diff --git a/docs/api.rst b/docs/api.rst index 8f8dd76..c47512c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,37 +1,25 @@ API === -.. autoclass:: django_admin_reversefields.mixins.ReverseRelationConfig - :members: - :show-inheritance: - :no-index: +.. contents:: Page contents + :depth: 1 + :local: - .. attribute:: bulk - :type: bool - :value: False +.. currentmodule:: django_admin_reversefields.mixins - When ``True``, use Django's ``.update()`` method for bind/unbind operations - instead of individual model saves. This provides better performance for large - datasets but bypasses model signals (``pre_save``, ``post_save``, etc.). +Overview +-------- - .. warning:: - - Bulk operations bypass Django model signals. Only enable bulk mode when - your application doesn't rely on ``pre_save``, ``post_save``, or other - model signals for the reverse relationship model. +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :template: autosummary/class_admin.rst - **Default:** ``False`` (maintains backward compatibility) + ReverseRelationAdminMixin - **Performance Trade-offs:** - - - **Advantages:** Reduced database round-trips, better performance with large datasets - - **Disadvantages:** No model signal processing, less granular error handling +.. autosummary:: + :toctree: _autosummary + :nosignatures: -.. autoclass:: django_admin_reversefields.mixins.ReverseRelationAdminMixin - :members: - :show-inheritance: - :no-index: - -.. autoclass:: django_admin_reversefields.mixins.ReversePermissionPolicy - :members: - :no-index: + ReverseRelationConfig + ReversePermissionPolicy diff --git a/docs/architecture.rst b/docs/architecture.rst index c6667df..daa4ca5 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -1,6 +1,10 @@ Architecture ============ +.. contents:: Page contents + :depth: 1 + :local: + High-level components --------------------- @@ -43,6 +47,9 @@ Form lifecycle 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 -------------------------- @@ -76,6 +83,13 @@ fields are synchronized inside a single transaction so either all :term:`binding 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 ----------------------- diff --git a/docs/caveats.rst b/docs/caveats.rst index e7d7bca..e3ad01a 100644 --- a/docs/caveats.rst +++ b/docs/caveats.rst @@ -57,6 +57,9 @@ The default widgets mirror the Django admin look-and-feel. Override see the :doc:`advanced` gallery for end-to-end examples. For request-aware choice limiting, revisit :doc:`querysets-and-widgets`. +For how fields are included in the form and when they are visible vs. disabled, +see :doc:`rendering`. + Permission interplay -------------------- @@ -64,12 +67,18 @@ Permission interplay When ``reverse_permissions_enabled=True`` the mixin requires the user to pass one of the configured :term:`policies ` before persisting changes. - Denied fields are ignored during save, so crafted POSTs cannot sidestep the - check. The render-time behaviour is controlled by + 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 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. + One-to-one specifics -------------------- diff --git a/docs/conf.py b/docs/conf.py index 7781fa6..a011687 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,9 +13,9 @@ # serve to show the default. import datetime +from importlib.metadata import version as pkg_version import os import sys -from importlib.metadata import version as pkg_version # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -158,26 +158,33 @@ }, { "title": "Examples", - "url": "writing", + "url": "recipes", "children": [ { - "title": "Recipe 1", - "url": "recipes", - "summary": "description of recipe 1", + "title": "Single Binding", + "url": "recipes.html#single-binding-company-department", + "summary": "Recipe for a single binding (Parent ↔ Child)", "resource": True, }, { - "title": "Recipe 2", - "url": "recipes", - "summary": "description of recipe 2", + "title": "Multiple Binding", + "url": "recipes.html#multiple-binding-company-projects", + "summary": "Recipe for a multiple binding (Parent ↔ Children)", "resource": True, }, { - "title": "Recipe 3", - "url": "recipes", - "summary": "description of recipe 3", + "title": "Validation Hooks", + "url": "recipes.html#validation-hooks-business-rules", + "summary": "Recipe for a validation hook (Business Rules)", "resource": True, }, + { + "title": "Permissions", + "url": "recipes.html#permissions-on-reverse-fields", + "summary": "Recipe for a permissions on reverse fields", + "resource": True, + } + ] }, # { diff --git a/docs/core-concepts.rst b/docs/core-concepts.rst index 1565539..472fd5d 100644 --- a/docs/core-concepts.rst +++ b/docs/core-concepts.rst @@ -1,4 +1,4 @@ -Core concepts +Core Concepts ============= This chapter introduces the :term:`virtual fields ` that @@ -7,16 +7,32 @@ This chapter introduces the :term:`virtual fields ` that 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. +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 @@ -24,6 +40,9 @@ the mixin removes those virtual names from the base form 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 ----------------------- diff --git a/docs/data-integrity.rst b/docs/data-integrity.rst index d037022..b5469cf 100644 --- a/docs/data-integrity.rst +++ b/docs/data-integrity.rst @@ -1,6 +1,10 @@ -Data integrity & transactions +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 @@ -33,3 +37,8 @@ One-to-one specifics 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/index.rst b/docs/index.rst index f91bedd..0a22554 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ Contents :maxdepth: 1 data-integrity + rendering querysets-and-widgets permissions-guide advanced diff --git a/docs/permissions-guide.rst b/docs/permissions-guide.rst index e823094..2d50540 100644 --- a/docs/permissions-guide.rst +++ b/docs/permissions-guide.rst @@ -5,6 +5,10 @@ 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 ---------------- @@ -38,13 +42,13 @@ must therefore handle ``selection=None`` sensibly. Example:: - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True reverse_render_uses_field_policy = True reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=False, permission=lambda request, obj, config, selection: getattr(request.user, "is_staff", False), ) @@ -95,9 +99,13 @@ Permission checks run at three points: 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. + 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 ------------------------ @@ -203,3 +211,8 @@ Minimal examples 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. diff --git a/docs/querysets-and-widgets.rst b/docs/querysets-and-widgets.rst index 7bbeebf..0b3f53d 100644 --- a/docs/querysets-and-widgets.rst +++ b/docs/querysets-and-widgets.rst @@ -1,6 +1,10 @@ -Querysets & widgets +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`. @@ -14,6 +18,26 @@ Django's ``ModelAdmin`` behaviour) or a callable of the form 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 ------------------- @@ -31,4 +55,6 @@ 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` for a complete DAL example. + - :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 a81541e..0dbb699 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -3,8 +3,7 @@ Quickstart Get up and running with :class:`~django_admin_reversefields.mixins.ReverseRelationAdminMixin` in just a -few steps. The snippets below are pulled directly from the accompanying test -project so they always reflect a working configuration. +few steps. .. contents:: Page contents :depth: 1 @@ -51,7 +50,7 @@ objects. Each configuration describes which reverse-side model and .. literalinclude:: ../tests/admin.py :language: python - :lines: 1-8 + :lines: 6-13 :caption: Required imports in ``admin.py`` Register your :class:`~django.contrib.admin.ModelAdmin` by inheriting from the @@ -59,9 +58,9 @@ mixin and declaring at least one reverse relation: .. literalinclude:: ../tests/admin.py :language: python - :lines: 10-28 + :lines: 16-46 :caption: Minimal admin exposing two reverse bindings - :emphasize-lines: 4-16 + :emphasize-lines: 13-31 1. ``reverse_relations`` is a ``dict`` keyed by virtual field name. 2. Each :class:`~django_admin_reversefields.mixins.ReverseRelationConfig` @@ -92,14 +91,14 @@ instance. def unbound_or_current(queryset, instance, request): if instance and instance.pk: - return queryset.filter(Q(service__isnull=True) | Q(service=instance)) - return queryset.filter(service__isnull=True) + return queryset.filter(Q(company__isnull=True) | Q(company=instance)) + return queryset.filter(company__isnull=True) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", limit_choices_to=unbound_or_current, ) } @@ -122,40 +121,40 @@ instance. Enable bulk operations for performance -------------------------------------- -For large datasets where model signals aren't required, enable bulk mode to use +For large datasets where model signals aren't required, enable bulk mode to use Django's ``.update()`` method instead of individual saves: .. code-block:: python - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { # Single-select with bulk operations - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", bulk=True, # Use .update() for better performance limit_choices_to=unbound_or_current, ), # Multi-select with bulk operations - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", + "assigned_projects": ReverseRelationConfig( + model=Project, + fk_field="company", multiple=True, bulk=True, # Bulk operations for multiple selections - ordering=("number",), + ordering=("name",), ) } .. warning:: **When to use bulk mode:** - + - ✅ Large datasets (hundreds/thousands of objects) - ✅ Performance is critical - ✅ No dependency on model signals (``pre_save``, ``post_save``, etc.) - + **When NOT to use bulk mode:** - + - ❌ Your models rely on ``pre_save`` or ``post_save`` signals - ❌ You need granular error handling per object - ❌ Small datasets where performance isn't a concern diff --git a/docs/recipes.rst b/docs/recipes.rst index 267a1b2..aa32968 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -1,10 +1,14 @@ Recipes ======= +.. contents:: Page contents + :depth: 1 + :local: + .. _recipe-single-binding: -Single binding (Service ↔ Site) -------------------------------- +Single binding (Company ↔ Department) +------------------------------------- .. code-block:: python @@ -14,59 +18,63 @@ Single binding (Service ↔ Site) def unbound_or_current(queryset, instance, request): if instance and instance.pk: - return queryset.filter(Q(service__isnull=True) | Q(service=instance)) - return queryset.filter(service__isnull=True) + return queryset.filter(Q(company__isnull=True) | Q(company=instance)) + return queryset.filter(company__isnull=True) - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", limit_choices_to=unbound_or_current, # Add bulk=True for better performance with large datasets # bulk=True, # Uncomment if you don't need model signals ) } - fieldsets = (("Binding", {"fields": ("site_binding",)}),) + fieldsets = (("Departments", {"fields": ("department_binding",)}),) .. note:: Rendering Rules - If you declare ``fieldsets`` or ``fields``, include the virtual name - (e.g., ``"site_binding"``) so Django renders it. + (e.g., ``"department_binding"``) so Django renders it. - If neither is declared, Django renders all form fields and the injected - virtual fields appear automatically. + virtual fields appear automatically. The mixin appends the virtual names in + ``get_fields`` and injects their form fields in ``get_form``. + - If you override ``get_fields`` without calling ``super()``, or you return a + custom ``fields`` list that omits the virtual names, the admin template + will not render them (even though the form contains them). .. _recipe-multiple-binding: -Multiple binding (Service ↔ Extensions) ---------------------------------------- +Multiple binding (Company ↔ Projects) +------------------------------------- .. code-block:: python from django.db.models import Q - def available_extensions_queryset(queryset, instance, request): + def available_projects_queryset(queryset, instance, request): if instance and instance.pk: - return queryset.filter(Q(service__isnull=True) | Q(service=instance)) - return queryset.filter(service__isnull=True) + return queryset.filter(Q(company__isnull=True) | Q(company=instance)) + return queryset.filter(company__isnull=True) - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", + "assigned_projects": ReverseRelationConfig( + model=Project, + fk_field="company", multiple=True, - ordering=("number",), - limit_choices_to=available_extensions_queryset, - # Enable bulk operations for better performance with many extensions + ordering=("name",), + limit_choices_to=available_projects_queryset, + # Enable bulk operations for better performance with many projects # bulk=True, # Uncomment if you don't need model signals ) } - fieldsets = (("Binding", {"fields": ("assigned_extensions",)}),) + fieldsets = (("Projects", {"fields": ("assigned_projects",)}),) # Rendering rules are the same as the single-binding recipe. # Ensure all updates occur as a single unit (default True) reverse_relations_atomic = True @@ -84,14 +92,14 @@ Forbid unbinding unless a condition is met: def forbid_unbind(instance, selection, request): if selection is None: - raise forms.ValidationError("Cannot unbind site right now") + raise forms.ValidationError("Cannot unbind department right now") - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=False, clean=forbid_unbind, ) @@ -112,14 +120,14 @@ Permissions on reverse fields .. code-block:: python - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True reverse_permission_mode = "disable" # or "hide" reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=False, ) } @@ -137,13 +145,13 @@ Permissions on reverse fields # delegate to some legacy checker return legacy_can_bind(request.user, selection) - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=False, permission=CanBindAdapter(), ) @@ -153,7 +161,7 @@ Permissions on reverse fields .. code-block:: python - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True def has_reverse_change_permission(self, request, obj, config, selection=None): # Example: object-level check (works with backends that support object perms) @@ -169,7 +177,7 @@ Permissions on reverse fields .. code-block:: python - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True def has_reverse_change_permission(self, request, obj, config, selection=None): app, model = config.model._meta.app_label, config.model._meta.model_name @@ -183,13 +191,13 @@ Permissions on reverse fields def only_allow_special(request, obj, config, selection): return getattr(selection, "name", "") == "Special" - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=False, permission=only_allow_special, # Optional field override for the message; otherwise the policy @@ -208,13 +216,13 @@ Permissions on reverse fields def __call__(self, request, obj, config, selection): return getattr(request.user, "is_staff", False) - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=False, permission=StaffOnlyPolicy(), ) @@ -230,7 +238,7 @@ Permissions on reverse fields model = config.model._meta.model_name return request.user.has_perm(f"{app}.can_bind_{model}") - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True reverse_permission_policy = staticmethod(can_bind) @@ -242,7 +250,7 @@ Permissions on reverse fields .. code-block:: python - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True def get_form(self, request, obj=None, **kwargs): @@ -250,28 +258,28 @@ Permissions on reverse fields self.reverse_permission_policy = can_bind return super().get_form(request, obj, **kwargs) -OneToOneField (Parent ↔ Child) ------------------------------- +OneToOneField (Company ↔ CompanySettings) +----------------------------------------- .. code-block:: python def only_unbound_or_current(qs, instance, request): if instance and instance.pk: - return qs.filter(Q(my_o2o__isnull=True) | Q(my_o2o=instance)) - return qs.filter(my_o2o__isnull=True) + return qs.filter(Q(company__isnull=True) | Q(company=instance)) + return qs.filter(company__isnull=True) - @admin.register(Parent) - class ParentAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "child_binding": ReverseRelationConfig( - model=Child, - fk_field="my_o2o", + "settings_binding": ReverseRelationConfig( + model=CompanySettings, + fk_field="company", multiple=False, required=True, limit_choices_to=only_unbound_or_current, ) } - fieldsets = (("Binding", {"fields": ("child_binding",)}),) + fieldsets = (("Settings", {"fields": ("settings_binding",)}),) Request-aware validation hook (use request.user) ------------------------------------------------ @@ -289,19 +297,19 @@ The ``clean`` hook receives the ``request`` so you can implement user-specific r if not getattr(request, "user", None) or not request.user.is_staff: raise forms.ValidationError("Not permitted") - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=False, required=False, clean=staff_only, # gets (instance, selection, request) ) } -If the user is not staff, the form will include a field error on ``site_binding`` +If the user is not staff, the form will include a field error on ``department_binding`` with the message "Not permitted" and the update will be blocked. .. _recipe-render-policy: @@ -326,15 +334,15 @@ other context before any selection exists. def staff_only_policy(request, obj, config, selection): return getattr(request.user, "is_staff", False) - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_permissions_enabled = True reverse_permission_mode = "hide" # or "disable" reverse_render_uses_field_policy = True reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=False, permission=staff_only_policy, ) @@ -359,42 +367,42 @@ significant performance improvements but bypasses model signals. from django.db.models import Q from django_admin_reversefields.mixins import ReverseRelationAdminMixin, ReverseRelationConfig - def available_sites_queryset(queryset, instance, request): + def available_departments_queryset(queryset, instance, request): if instance and instance.pk: - return queryset.filter(Q(service__isnull=True) | Q(service=instance)) - return queryset.filter(service__isnull=True) + return queryset.filter(Q(company__isnull=True) | Q(company=instance)) + return queryset.filter(company__isnull=True) - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { # Single-select with bulk operations - "primary_site": ReverseRelationConfig( - model=Site, - fk_field="primary_service", + "primary_department": ReverseRelationConfig( + model=Department, + fk_field="company", bulk=True, # Use bulk operations for performance - limit_choices_to=available_sites_queryset, + limit_choices_to=available_departments_queryset, ), # Multi-select with bulk operations - ideal for large datasets - "all_sites": ReverseRelationConfig( - model=Site, - fk_field="service", + "all_projects": ReverseRelationConfig( + model=Project, + fk_field="company", multiple=True, bulk=True, # Bulk operations for multiple selections ordering=("name",), - limit_choices_to=available_sites_queryset, + limit_choices_to=available_departments_queryset, ), # Mixed configuration - some bulk, some individual - "critical_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", + "critical_departments": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=True, bulk=False, # Keep individual saves for signal processing - ordering=("priority", "name"), + ordering=("name",), ) } fieldsets = ( - ("Site Assignments", {"fields": ("primary_site", "all_sites")}), - ("Extensions", {"fields": ("critical_extensions",)}), + ("Department Assignments", {"fields": ("primary_department", "all_projects")}), + ("Critical Departments", {"fields": ("critical_departments",)}), ) .. note:: **Performance Guidelines** @@ -407,7 +415,7 @@ significant performance improvements but bypasses model signals. .. code-block:: python - # Example performance difference with 1000 Site objects: + # Example performance difference with 1000 Department objects: # Individual saves (bulk=False): # - 1000+ database queries (one per save) @@ -421,12 +429,12 @@ significant performance improvements but bypasses model signals. # Use bulk mode for this scenario: reverse_relations = { - "site_assignments": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_assignments": ReverseRelationConfig( + model=Department, + fk_field="company", multiple=True, bulk=True, # 10-50x performance improvement - ordering=("region", "name"), + ordering=("name",), ) } @@ -467,15 +475,15 @@ Third-party widgets (AJAX search) # In forms.py or a dedicated file from dal import autocomplete - from .models import Site + from .models import Department - class SiteAutocomplete(autocomplete.Select2QuerySetView): + class DepartmentAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): # Don't forget to filter out results based on user permissions if not self.request.user.is_authenticated: - return Site.objects.none() + return Department.objects.none() - qs = Site.objects.all() + qs = Department.objects.all() if self.q: qs = qs.filter(name__icontains=self.q) @@ -489,13 +497,13 @@ Third-party widgets (AJAX search) .. code-block:: python # In urls.py - from .forms import SiteAutocomplete + from .forms import DepartmentAutocomplete urlpatterns = [ path( - "site-autocomplete/", - SiteAutocomplete.as_view(), - name="site-autocomplete", + "department-autocomplete/", + DepartmentAutocomplete.as_view(), + name="department-autocomplete", ), ] @@ -508,21 +516,21 @@ Third-party widgets (AJAX search) # In admin.py from dal import autocomplete - @admin.register(Service) - class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + @admin.register(Company) + class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", # The widget is instantiated with the URL of the autocomplete view - widget=autocomplete.ModelSelect2(url="site-autocomplete"), + widget=autocomplete.ModelSelect2(url="department-autocomplete"), ) } - fieldsets = (("Binding", {"fields": ("site_binding",)}),) + fieldsets = (("Departments", {"fields": ("department_binding",)}),) class Media: js = ("admin/js/jquery.init.js",) # Ensure jQuery is loaded .. note:: The admin must include the necessary form media for the widget to work. - Ensure jQuery is loaded if your widget depends on it. \ No newline at end of file + Ensure jQuery is loaded if your widget depends on it. diff --git a/docs/rendering.rst b/docs/rendering.rst new file mode 100644 index 0000000..1618a07 --- /dev/null +++ b/docs/rendering.rst @@ -0,0 +1,84 @@ +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/pyproject.toml b/pyproject.toml index 2be22fd..61e346f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,10 +29,6 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content", - "Topic :: Internet :: WWW/HTTP :: WSGI", - "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Development Status :: 4 - Beta", ] @@ -80,3 +76,12 @@ ignore = [] [tool.ruff.format] quote-style = "double" indent-style = "space" +docstring-code-format = true + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +known-first-party = ["django_admin_reversefields"] +combine-as-imports = true +force-sort-within-sections = true diff --git a/tests/admin.py b/tests/admin.py index f89fd99..cc271aa 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -1,3 +1,8 @@ +""" +Admin configurations following quickstart documentation patterns. +""" + +# Required imports from quickstart guide from django.contrib import admin from django_admin_reversefields.mixins import ( @@ -5,24 +10,97 @@ ReverseRelationConfig, ) -from .models import Extension, Service, Site +from .models import Assignment, Company, CompanySettings, Department, Employee, Project + + +@admin.register(Company) +class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + """ + Company admin following quickstart patterns. + Following the quickstart guide step-by-step: + 1. Inherit from ReverseRelationAdminMixin and admin.ModelAdmin + 2. Declare reverse_relations dict with virtual field names as keys + 3. Include virtual field names in fieldsets + """ -@admin.register(Service) -class ServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + # Step 1: Declare reverse_relations dict keyed by virtual field name reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - label="Site", + # Single department selection - following minimal example pattern + "departments": ReverseRelationConfig( + model=Department, + fk_field="company", ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - label="Extensions", + # Multiple projects - using multiple=True from configuration highlights + "projects": ReverseRelationConfig( + model=Project, + fk_field="company", multiple=True, - ordering=("number",), ), } - fieldsets = (("Binding", {"fields": ("site_binding", "assigned_extensions")}),) + # Step 2: Include virtual field names in fieldsets as instructed + fieldsets = ( + ("Company Information", {"fields": ("name", "founded_year")}), + ("Related Items", {"fields": ("departments", "projects")}), + ) + + +@admin.register(Department) +class DepartmentAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + """ + Department admin following quickstart patterns. + + Following the same pattern as CompanyAdmin but for Department model. + """ + + # Following the same pattern: dict keyed by virtual field name + reverse_relations = { + # Single employee selection for department head + "employees": ReverseRelationConfig( + model=Employee, + fk_field="department", + ), + } + + # Include virtual field names in fieldsets + fieldsets = ( + ("Department Information", {"fields": ("name", "company", "budget")}), + ("Staff", {"fields": ("employees",)}), + ) + + +# Basic admin registrations for other models (no reverse relationships needed for this task) +@admin.register(Employee) +class EmployeeAdmin(admin.ModelAdmin): + """Basic admin for Employee model.""" + + list_display = ("name", "email", "department", "hire_date") + list_filter = ("department", "hire_date") + search_fields = ("name", "email") + + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + """Basic admin for Project model.""" + + list_display = ("name", "company", "is_active", "start_date", "end_date") + list_filter = ("company", "is_active", "start_date") + search_fields = ("name",) + + +@admin.register(Assignment) +class AssignmentAdmin(admin.ModelAdmin): + """Basic admin for Assignment model.""" + + list_display = ("employee", "project", "role", "hours_allocated", "start_date") + list_filter = ("role", "project__company", "start_date") + search_fields = ("employee__name", "project__name", "role") + + +@admin.register(CompanySettings) +class CompanySettingsAdmin(admin.ModelAdmin): + """Basic admin for CompanySettings model.""" + + list_display = ("company", "timezone", "fiscal_year_start", "allow_remote_work") + list_filter = ("timezone", "allow_remote_work") diff --git a/tests/models.py b/tests/models.py index c1b560b..731e065 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,47 +1,122 @@ +""" +Business domain models for testing django-admin-reversefields package. +""" + from django.db import models -class Service(models.Model): - name = models.CharField(max_length=50) +class Company(models.Model): + """ + Root entity representing a business organization. + + This model serves as the parent for departments and projects, + demonstrating one-to-many reverse relationships + """ + + name = models.CharField(max_length=100) + founded_year = models.IntegerField(null=True, blank=True) + + class Meta: + verbose_name_plural = "companies" def __str__(self) -> str: # pragma: no cover - repr helper for admin/tests - return self.name + return str(self.name) -class Site(models.Model): - name = models.CharField(max_length=50) - service = models.ForeignKey( - Service, on_delete=models.SET_NULL, null=True, blank=True, related_name="sites" +class Department(models.Model): + """ + Organizational unit within a company. + + Demonstrates a non-nullable foreign key relationship to Company + """ + + name = models.CharField(max_length=100) + company = models.ForeignKey( + Company, on_delete=models.CASCADE, related_name="departments", null=True, blank=True ) + budget = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) def __str__(self) -> str: # pragma: no cover - repr helper for admin/tests - return self.name + return f"{self.name} ({self.company.name if self.company else 'Unassigned'})" + +class Employee(models.Model): + """ + Individual worker within a department. -class Extension(models.Model): - number = models.CharField(max_length=10) - service = models.ForeignKey( - Service, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="extensions", + Demonstrates a nullable foreign key relationship to Department + """ + + name = models.CharField(max_length=100) + email = models.EmailField(unique=True) + department = models.ForeignKey( + Department, on_delete=models.SET_NULL, null=True, blank=True, related_name="employees" ) + hire_date = models.DateField(null=True, blank=True) def __str__(self) -> str: # pragma: no cover - repr helper for admin/tests - return self.number - - -class UniqueExtension(models.Model): - number = models.CharField(max_length=10) - # Enforce at most one UniqueExtension per Service to simulate unique FK/O2O - service = models.OneToOneField( - Service, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="unique_extension", + dept_name = self.department.name if self.department else "Unassigned" + return f"{self.name} ({dept_name})" + + +class Project(models.Model): + """ + Work initiative within a company. + + Demonstrates another non-nullable relationship to Company, + and serves as the target for many-to-many relationships + through Assignment model. + """ + + name = models.CharField(max_length=100) + company = models.ForeignKey( + Company, on_delete=models.CASCADE, related_name="projects", null=True, blank=True ) + start_date = models.DateField(null=True, blank=True) + end_date = models.DateField(null=True, blank=True) + is_active = models.BooleanField(default=True) + + def __str__(self) -> str: # pragma: no cover - repr helper for admin/tests + return f"{self.name} ({self.company.name if self.company else 'Unassigned'})" + + +class Assignment(models.Model): + """ + Junction model connecting employees to projects with additional data. + + Demonstrates a many-to-many relationship with additional fields + """ + + employee = models.ForeignKey(Employee, on_delete=models.CASCADE, related_name="assignments") + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="assignments") + role = models.CharField(max_length=50) + hours_allocated = models.IntegerField(default=40) + start_date = models.DateField(null=True, blank=True) + + class Meta: + unique_together = ("employee", "project") + + def __str__(self) -> str: # pragma: no cover - repr helper for admin/tests + return f"{self.employee.name} - {self.project.name} ({self.role})" + + +class CompanySettings(models.Model): + """ + One-to-one relationship model for company configuration. + + Demonstrates a unique/one-to-one reverse relationship + Uses nullable relationship to allow companies without settings. + """ + + company = models.OneToOneField( + Company, on_delete=models.CASCADE, related_name="settings", null=True, blank=True + ) + timezone = models.CharField(max_length=50, default="UTC") + fiscal_year_start = models.IntegerField(default=1) # Month number + allow_remote_work = models.BooleanField(default=False) + + class Meta: + verbose_name_plural = "company settings" def __str__(self) -> str: # pragma: no cover - repr helper for admin/tests - return self.number + return f"Settings for {self.company.name}" diff --git a/tests/parameterized/__init__.py b/tests/parameterized/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/parameterized/test_binding.py b/tests/parameterized/test_binding.py new file mode 100644 index 0000000..3df8c85 --- /dev/null +++ b/tests/parameterized/test_binding.py @@ -0,0 +1,427 @@ +"""Parameterized tests for binding operations.""" + +# Test imports +from django.contrib import admin + +from django_admin_reversefields.mixins import ( + ReverseRelationAdminMixin, + ReverseRelationConfig, +) + +from ..models import Company, Department, Project +from ..shared_test_base import BaseAdminMixinTestCase +from .utils import create_parameterized_admin + + +class ParameterizedBindingTests(BaseAdminMixinTestCase): + """Test core binding operations with both bulk=True and bulk=False.""" + + def test_single_select_binding_both_modes(self): + """Test single-select binding works consistently in both bulk and non-bulk modes.""" + # Create test data + dept_a = Department.objects.create(name="Department A") + dept_b = Department.objects.create(name="Department B") + + # Test both bulk modes + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + # Create fresh company for each test + company = Company.objects.create(name=f"test-company-{bulk_enabled}") + + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, company) + + # Test initial binding + form = form_cls( + {"name": company.name, "department_binding": dept_a.pk}, instance=company + ) + self.assertTrue(form.is_valid(), f"Form should be valid for bulk={bulk_enabled}") + obj = form.save() + + # Verify binding worked + dept_a.refresh_from_db() + self.assertEqual( + dept_a.company, obj, f"Department A should be bound for bulk={bulk_enabled}" + ) + + # Test changing binding + form = form_cls( + {"name": company.name, "department_binding": dept_b.pk}, instance=obj + ) + self.assertTrue( + form.is_valid(), f"Form should be valid for rebinding with bulk={bulk_enabled}" + ) + obj = form.save() + + # Verify rebinding worked + dept_a.refresh_from_db() + dept_b.refresh_from_db() + self.assertIsNone( + dept_a.company, f"Department A should be unbound for bulk={bulk_enabled}" + ) + self.assertEqual( + dept_b.company, obj, f"Department B should be bound for bulk={bulk_enabled}" + ) + + def test_multiple_select_binding_both_modes(self): + """Test multi-select binding works consistently in both bulk and non-bulk modes.""" + # Test both bulk modes + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + # Create fresh test data for each iteration + project_1 = Project.objects.create(name=f"Project-{bulk_enabled}-1") + project_2 = Project.objects.create(name=f"Project-{bulk_enabled}-2") + project_3 = Project.objects.create(name=f"Project-{bulk_enabled}-3") + company = Company.objects.create(name=f"test-company-multi-{bulk_enabled}") + + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, company) + + # Test initial multi-binding + form = form_cls( + {"name": company.name, "assigned_projects": [project_1.pk, project_2.pk]}, + instance=company, + ) + self.assertTrue( + form.is_valid(), + f"Form should be valid for multi-select with bulk={bulk_enabled}", + ) + obj = form.save() + + # Verify multi-binding worked + project_1.refresh_from_db() + project_2.refresh_from_db() + project_3.refresh_from_db() + self.assertEqual( + project_1.company, obj, f"Project 1 should be bound for bulk={bulk_enabled}" + ) + self.assertEqual( + project_2.company, obj, f"Project 2 should be bound for bulk={bulk_enabled}" + ) + self.assertIsNone( + project_3.company, f"Project 3 should be unbound for bulk={bulk_enabled}" + ) + + # Test changing multi-selection + form = form_cls( + {"name": company.name, "assigned_projects": [project_2.pk, project_3.pk]}, + instance=obj, + ) + self.assertTrue( + form.is_valid(), + f"Form should be valid for multi-rebinding with bulk={bulk_enabled}", + ) + obj = form.save() + + # Verify multi-rebinding worked + project_1.refresh_from_db() + project_2.refresh_from_db() + project_3.refresh_from_db() + self.assertIsNone( + project_1.company, f"Project 1 should be unbound for bulk={bulk_enabled}" + ) + self.assertEqual( + project_2.company, obj, f"Project 2 should remain bound for bulk={bulk_enabled}" + ) + self.assertEqual( + project_3.company, + obj, + f"Project 3 should be newly bound for bulk={bulk_enabled}", + ) + + def test_empty_selection_handling_both_modes(self): + """Test empty selection handling works consistently in both modes.""" + # Create test data + dept_a = Department.objects.create(name="Department A") + project_1 = Project.objects.create(name="Project 1") + + # Test both bulk modes + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + # Create fresh company for each test + company = Company.objects.create(name=f"test-company-empty-{bulk_enabled}") + + # Initially bind objects + dept_a.company = company + dept_a.save() + project_1.company = company + project_1.save() + + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, company) + + # Test clearing single-select + form = form_cls({"name": company.name, "department_binding": ""}, instance=company) + self.assertTrue( + form.is_valid(), + f"Form should be valid for empty single-select with bulk={bulk_enabled}", + ) + obj = form.save() + + # Verify unbinding worked + dept_a.refresh_from_db() + self.assertIsNone( + dept_a.company, f"Department should be unbound for bulk={bulk_enabled}" + ) + + # Test clearing multi-select + form = form_cls({"name": company.name, "assigned_projects": []}, instance=obj) + self.assertTrue( + form.is_valid(), + f"Form should be valid for empty multi-select with bulk={bulk_enabled}", + ) + obj = form.save() + + # Verify multi-unbinding worked + project_1.refresh_from_db() + self.assertIsNone( + project_1.company, f"Project should be unbound for bulk={bulk_enabled}" + ) + + def test_empty_queryset_handling_both_modes(self): + """Test operations with completely empty querysets in both modes.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + Department.objects.all().delete() + Project.objects.all().delete() + + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, self.company) + form = form_cls(instance=self.company) + + self.assertIn("department_binding", form.fields) + self.assertIn("assigned_projects", form.fields) + + dept_field = form.fields["department_binding"] + proj_field = form.fields["assigned_projects"] + + if hasattr(dept_field, "queryset"): + self.assertEqual(dept_field.queryset.count(), 0) + if hasattr(proj_field, "queryset"): + self.assertEqual(proj_field.queryset.count(), 0) + + form_data = { + "name": self.company.name, + "department_binding": "", + "assigned_projects": [], + } + form_with_data = form_cls(form_data, instance=self.company) + self.assertTrue(form_with_data.is_valid()) + + def test_limit_choices_to_dict_both_modes(self): + """Static dict limiters filter choices as expected in both modes.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + other_company = Company.objects.create(name=f"other-{bulk_enabled}") + unbound = Project.objects.create(name=f"free-{bulk_enabled}") + bound_elsewhere = Project.objects.create(name=f"busy-{bulk_enabled}", company=other_company) + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "assigned_projects": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=True, + bulk=bulk_enabled, + limit_choices_to={"company__isnull": True}, + ) + } + + admin_instance = TestAdmin(Company, self.site) + request = self.factory.get("/") + form_cls = admin_instance.get_form(request, self.company) + form = form_cls(instance=self.company) + + field = form.fields["assigned_projects"] + # Only the unbound project should be offered by the static dict limiter + if hasattr(field, "queryset"): + self.assertEqual(list(field.queryset.values_list("pk", flat=True)), [unbound.pk]) + + # Selecting the unbound project is valid and should bind it + form_select = form_cls( + {"name": self.company.name, "assigned_projects": [unbound.pk]}, + instance=self.company, + ) + self.assertTrue(form_select.is_valid()) + saved_company = form_select.save() + + unbound.refresh_from_db() + bound_elsewhere.refresh_from_db() + self.assertEqual(unbound.company, saved_company) + self.assertEqual(bound_elsewhere.company, other_company) + + def test_single_item_selection_edge_cases_both_modes(self): + """Test operations with exactly one item available in both modes.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + Department.objects.all().delete() + Project.objects.all().delete() + + single_project = Project.objects.create(name="Only Project") + single_dept = Department.objects.create(name="Only Department") + + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, self.company) + + form = form_cls( + { + "name": self.company.name, + "department_binding": single_dept.pk, + "assigned_projects": [single_project.pk], + }, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + saved_company = form.save() + + single_project.refresh_from_db() + single_dept.refresh_from_db() + self.assertEqual(single_project.company, saved_company) + self.assertEqual(single_dept.company, saved_company) + + form_deselect = form_cls( + {"name": self.company.name, "department_binding": "", "assigned_projects": []}, + instance=self.company, + ) + + self.assertTrue(form_deselect.is_valid()) + form_deselect.save() + + single_project.refresh_from_db() + single_dept.refresh_from_db() + self.assertIsNone(single_project.company) + self.assertIsNone(single_dept.company) + + def test_pre_existing_bindings_both_modes(self): + """Test operations when objects already have existing bindings in both modes.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + other_company = Company.objects.create(name="other-company") + + project_a = Project.objects.create(name="Project A", company=other_company) + project_b = Project.objects.create(name="Project B") # Unbound + dept_a = Department.objects.create(name="Department A", company=other_company) + dept_b = Department.objects.create(name="Department B") # Unbound + + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, self.company) + + form = form_cls( + { + "name": self.company.name, + "department_binding": dept_a.pk, # Currently bound to other_company + "assigned_projects": [project_a.pk, project_b.pk], + }, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + saved_company = form.save() + + project_a.refresh_from_db() + project_b.refresh_from_db() + dept_a.refresh_from_db() + dept_b.refresh_from_db() + + self.assertEqual(project_a.company, saved_company) + self.assertEqual(project_b.company, saved_company) + self.assertEqual(dept_a.company, saved_company) + self.assertEqual(Department.objects.filter(company=other_company).count(), 0) + + def test_model_creation_vs_update_scenarios_both_modes(self): + """Test operations in both model creation and update scenarios in both modes.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + project_a = Project.objects.create(name="Project A") + project_b = Project.objects.create(name="Project B") + dept_a = Department.objects.create(name="Department A") + dept_b = Department.objects.create(name="Department B") + + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + request = self.factory.post("/") + + # Test creation scenario + new_company = Company(name="new-company") + form_cls_create = admin_instance.get_form(request, new_company) + form_create = form_cls_create( + { + "name": new_company.name, + "department_binding": dept_a.pk, + "assigned_projects": [project_a.pk], + }, + instance=new_company, + ) + + self.assertTrue(form_create.is_valid()) + created_company = form_create.save() + + project_a.refresh_from_db() + dept_a.refresh_from_db() + self.assertEqual(project_a.company, created_company) + self.assertEqual(dept_a.company, created_company) + + # Test update scenario + form_cls_update = admin_instance.get_form(request, created_company) + form_update = form_cls_update( + { + "name": created_company.name, + "department_binding": dept_b.pk, + "assigned_projects": [project_b.pk], + }, + instance=created_company, + ) + + self.assertTrue(form_update.is_valid()) + updated_company = form_update.save() + + project_a.refresh_from_db() + project_b.refresh_from_db() + dept_a.refresh_from_db() + dept_b.refresh_from_db() + + self.assertIsNone(project_a.company) + self.assertEqual(project_b.company, updated_company) + self.assertIsNone(dept_a.company) + self.assertEqual(dept_b.company, updated_company) + + def test_unsaved_model_instance_both_modes(self): + """Test operations with unsaved model instances in both modes.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + unsaved_company = Company(name="unsaved-company") + + project_a = Project.objects.create(name="Project A") + dept_a = Department.objects.create(name="Department A") + + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, unsaved_company) + + form = form_cls( + { + "name": unsaved_company.name, + "department_binding": dept_a.pk, + "assigned_projects": [project_a.pk], + }, + instance=unsaved_company, + ) + + self.assertTrue(form.is_valid()) + + saved_company = form.save() + self.assertIsNotNone(saved_company.pk) + + project_a.refresh_from_db() + dept_a.refresh_from_db() + self.assertEqual(project_a.company, saved_company) + self.assertEqual(dept_a.company, saved_company) diff --git a/tests/parameterized/test_permissions.py b/tests/parameterized/test_permissions.py new file mode 100644 index 0000000..f99eb56 --- /dev/null +++ b/tests/parameterized/test_permissions.py @@ -0,0 +1,559 @@ +"""Parameterized tests for permission handling.""" + +# Django imports +from django.contrib import admin +from django.contrib.auth.models import User + +from django_admin_reversefields.mixins import ( + ReverseRelationAdminMixin, + ReverseRelationConfig, +) + +from ..models import Company, Department, Project + +# Test imports +from ..shared_test_base import BaseAdminMixinTestCase + + +class ParameterizedPermissionTests(BaseAdminMixinTestCase): + """Test permission scenarios work consistently in both bulk and non-bulk modes.""" + + admin_class = admin.ModelAdmin + + def test_permission_policy_consistency_both_modes(self): + """Test permission policies work consistently in both modes.""" + # Create test data + dept_a = Department.objects.create(name="Department A") + + # Test different permission scenarios + permission_scenarios = [ + ("allow_all", True), + ("deny_all", False), + ("staff_only", True), # We'll test with staff user + ] + + # Create users once for all test iterations to avoid unique constraint violations + regular_user = User.objects.create_user( + username="regular_perm_test", password="test", is_staff=False + ) + staff_user = User.objects.create_user( + username="staff_perm_test", password="test", is_staff=True + ) + + for policy_type, expected_access in permission_scenarios: + for bulk_enabled in [False, True]: + with self.subTest(policy_type=policy_type, bulk_enabled=bulk_enabled): + # Create permission policy + if policy_type == "allow_all": + policy = lambda request, obj, config, selection: True + elif policy_type == "deny_all": + policy = lambda request, obj, config, selection: False + elif policy_type == "staff_only": + policy = lambda request, obj, config, selection: getattr( + request.user, "is_staff", False + ) + else: + raise ValueError(f"Unknown policy_type: {policy_type}") + + # Create admin with permission policy + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_relations = { + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + permission=policy, + ) + } + + admin_instance = TestAdmin(Company, self.site) + + # Test with staff user (should have access for staff_only policy) + request = self.factory.get("/") + request.user = staff_user + + form_cls = admin_instance.get_form(request, self.company) + form = form_cls(instance=self.company) + + # Field should exist regardless of permission (permissions affect behavior, not existence) + self.assertIn( + "department_binding", + form.fields, + f"Field should exist for {policy_type} with bulk={bulk_enabled}", + ) + + def test_render_time_policy_flag_affects_visibility_and_editability(self): + """Per-field policy influences render only when flag is enabled. + + When base perms allow but the per-field policy denies, the field is: + - visible and enabled when reverse_render_uses_field_policy=False + - hidden/disabled based on reverse_permission_mode when True + """ + + class StubUser: + def __init__(self, is_staff=False): + self.is_staff = is_staff + + def has_perm(self, perm): + return True # Base change permission allowed + + deny_policy = staticmethod(lambda request, obj, config, selection: False) + + for permission_mode in ["hide", "disable"]: + for bulk_enabled in [False, True]: + with self.subTest(permission_mode=permission_mode, bulk_enabled=bulk_enabled): + # Case A: Flag disabled -> base perms allow -> field visible/editable + class AdminNoFlag(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = permission_mode + reverse_render_uses_field_policy = False + reverse_relations = { + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + permission=deny_policy, + ) + } + + req = self.factory.get("/") + req.user = StubUser() + admin_a = AdminNoFlag(Company, self.site) + form_cls_a = admin_a.get_form(req, self.company) + form_a = form_cls_a(instance=self.company) + + self.assertIn("department_binding", form_a.fields) + self.assertFalse(getattr(form_a.fields["department_binding"], "disabled", False)) + + # Case B: Flag enabled -> policy denies -> hidden/disabled according to mode + class AdminFlag(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = permission_mode + reverse_render_uses_field_policy = True + reverse_relations = { + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + permission=deny_policy, + ) + } + + admin_b = AdminFlag(Company, self.site) + form_cls_b = admin_b.get_form(req, self.company) + form_b = form_cls_b(instance=self.company) + + if permission_mode == "hide": + self.assertNotIn("department_binding", form_b.fields) + else: + self.assertIn("department_binding", form_b.fields) + self.assertTrue(form_b.fields["department_binding"].disabled) + + def test_persistence_gate_ignores_crafted_post_for_hidden_or_disabled_fields(self): + """Even if POST includes a disabled/hidden field, payload filtering blocks changes.""" + + deny_policy = staticmethod(lambda request, obj, config, selection: False) + + for permission_mode in ["hide", "disable"]: + for bulk_enabled in [False, True]: + with self.subTest(permission_mode=permission_mode, bulk_enabled=bulk_enabled): + dept = Department.objects.create(name="Dept X") + + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = permission_mode + reverse_render_uses_field_policy = True # consult per-field policy at render + reverse_relations = { + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + permission=deny_policy, + ) + } + + admin_inst = TestAdmin(Company, self.site) + request = self.factory.post("/") + form_cls = admin_inst.get_form(request, self.company) + form = form_cls( + {"name": self.company.name, "department_binding": dept.pk}, + instance=self.company, + ) + + # Form remains valid: disabled/hidden fields skip validation errors + self.assertTrue(form.is_valid()) + form.save() + + dept.refresh_from_db() + self.assertIsNone( + dept.company, + f"Crafted POST should be ignored in {permission_mode} mode with bulk={bulk_enabled}", + ) + + def test_global_policy_custom_message_precedence_both_modes(self): + """When only a global policy denies, its message appears on the field error.""" + + class GlobalMessagePolicy: + permission_denied_message = "Global policy denied this selection" + + def __call__(self, request, obj, config, selection): + # Allow at render (selection=None), deny when a selection exists + return selection is None + + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + project = Project.objects.create(name="Project A") + + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_policy = GlobalMessagePolicy() + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + ) + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + form = form_cls( + {"name": self.company.name, "project_binding": project.pk}, + instance=self.company, + ) + + self.assertFalse(form.is_valid()) + self.assertIn( + "global policy denied", + form.errors.get("project_binding", [""])[0].lower(), + ) + + def test_permission_callable_consistency_both_modes(self): + """Test permission callables work consistently in both modes.""" + # Create test data + project_1 = Project.objects.create(name="Project 1") + + def custom_permission(request, obj, config, selection): + """Custom permission that allows access only for specific users.""" + return hasattr(request.user, "username") and "staff" in request.user.username + + # Create users once to avoid unique constraint violations + staff_user = User.objects.create_user( + username="staff_callable_test", password="test", is_staff=True + ) + + # Test both bulk modes + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + # Create admin with permission callable + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_relations = { + "assigned_projects": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=True, + bulk=bulk_enabled, + permission=custom_permission, + ) + } + + admin_instance = TestAdmin(Company, self.site) + + # Test with staff user (should have access) + request = self.factory.get("/") + request.user = staff_user + + form_cls = admin_instance.get_form(request, self.company) + form = form_cls(instance=self.company) + + # Field should exist for staff user + self.assertIn( + "assigned_projects", + form.fields, + f"Field should exist for staff user with bulk={bulk_enabled}", + ) + + def test_permission_mode_consistency_both_modes(self): + """Test permission modes (hide/disable) work consistently in both modes.""" + # Create test data + dept_a = Department.objects.create(name="Department A") + + # Create users once to avoid unique constraint violations + regular_user = User.objects.create_user( + username="regular_mode_test", password="test", is_staff=False + ) + + # Create deny-all policy + deny_policy = lambda request, obj, config, selection: False + + # Test both permission modes + for permission_mode in ["hide", "disable"]: + for bulk_enabled in [False, True]: + with self.subTest(permission_mode=permission_mode, bulk_enabled=bulk_enabled): + # Create admin with permission mode + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = permission_mode + reverse_relations = { + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + permission=deny_policy, + ) + } + + admin_instance = TestAdmin(Company, self.site) + + # Test with regular user (should be denied) + request = self.factory.get("/") + request.user = regular_user + + form_cls = admin_instance.get_form(request, self.company) + form = form_cls(instance=self.company) + + if permission_mode == "hide": + # Field should not exist when hidden + self.assertNotIn( + "department_binding", + form.fields, + f"Field should be hidden for {permission_mode} with bulk={bulk_enabled}", + ) + else: # disable mode + # Field should exist but be disabled + self.assertIn( + "department_binding", + form.fields, + f"Field should exist but be disabled for {permission_mode} with bulk={bulk_enabled}", + ) + + def test_per_field_permission_callable_denies_both_modes(self): + """Test per-field permission callable that denies access for base operations.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + project_a = Project.objects.create(name="Project A") + + def deny_policy(request, obj, config, selection): + """Deny all access.""" + return False + + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = "disable" + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + permission=deny_policy, + bulk=bulk_enabled, + ) + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + form = form_cls( + {"name": self.company.name, "project_binding": project_a.pk}, + instance=self.company, + ) + + self.assertFalse(form.is_valid()) + self.assertIn("permission", form.errors.get("project_binding", [""])[0].lower()) + + # Verify no change was persisted + project_a.refresh_from_db() + self.assertIsNone(project_a.company) + + def test_per_field_permission_callable_allows_both_modes(self): + """Test per-field permission callable that allows access for base operations.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + project_a = Project.objects.create(name="Project A") + + def allow_policy(request, obj, config, selection): + """Allow all access.""" + return True + + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = "disable" + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + permission=allow_policy, + bulk=bulk_enabled, + ) + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + form = form_cls( + {"name": self.company.name, "project_binding": project_a.pk}, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + saved_company = form.save() + + # Verify the binding was created + project_a.refresh_from_db() + self.assertEqual(project_a.company, saved_company) + + def test_selection_based_permission_both_modes(self): + """Test per-field permission based on object selection for base operations.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + project_a = Project.objects.create(name="A") + project_b = Project.objects.create(name="B") + + class SelectivePolicy: + """Policy that allows only specific selections.""" + + def has_perm(self, request, obj, config, selection): + """Allow only selecting project with name 'B'.""" + if selection and getattr(selection, "name", None) == "B": + return True + return False + + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = "disable" + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + permission=SelectivePolicy(), + permission_denied_message="Not allowed for this selection", + bulk=bulk_enabled, + ) + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Test denied selection (Project A) + form_denied = form_cls( + {"name": self.company.name, "project_binding": project_a.pk}, + instance=self.company, + ) + self.assertFalse(form_denied.is_valid()) + self.assertIn( + "not allowed for this selection", + form_denied.errors.get("project_binding", [""])[0].lower(), + ) + + # Verify no binding was created + project_a.refresh_from_db() + self.assertIsNone(project_a.company) + + # Test allowed selection (Project B) + form_allowed = form_cls( + {"name": self.company.name, "project_binding": project_b.pk}, + instance=self.company, + ) + self.assertTrue(form_allowed.is_valid()) + saved_company = form_allowed.save() + + # Verify the allowed binding was created + project_b.refresh_from_db() + self.assertEqual(project_b.company, saved_company) + + def test_global_permission_policy_both_modes(self): + """Test global reverse permission policy for base operations.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + project_a = Project.objects.create(name="Project A") + + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = "disable" + # Global policy: deny if selection name is "Project A" (like the working test) + reverse_permission_policy = staticmethod( + lambda request, obj, config, selection: ( + False + if ( + selection is not None + and getattr(selection, "name", None) == "Project A" + ) + else True + ) + ) + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + ) + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + form = form_cls( + {"name": self.company.name, "project_binding": project_a.pk}, + instance=self.company, + ) + + self.assertFalse(form.is_valid()) + self.assertIn("permission", form.errors.get("project_binding", [""])[0].lower()) + + # Verify no binding was created + project_a.refresh_from_db() + self.assertIsNone(project_a.company) + + def test_policy_with_custom_message_both_modes(self): + """Test policy object with custom error message for base operations.""" + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + project_a = Project.objects.create(name="Project A") + + class CustomMessagePolicy: + """Policy with custom error message.""" + + permission_denied_message = "Custom field-specific error message" + + def __call__(self, request, obj, config, selection): + return False + + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_permissions_enabled = True + reverse_permission_mode = "disable" + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + permission=CustomMessagePolicy(), + bulk=bulk_enabled, + ) + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + form = form_cls( + {"name": self.company.name, "project_binding": project_a.pk}, + instance=self.company, + ) + + self.assertFalse(form.is_valid()) + self.assertIn( + "custom field-specific error message", + form.errors.get("project_binding", [""])[0].lower(), + ) diff --git a/tests/parameterized/test_validation.py b/tests/parameterized/test_validation.py new file mode 100644 index 0000000..b899ef2 --- /dev/null +++ b/tests/parameterized/test_validation.py @@ -0,0 +1,159 @@ +"""Parameterized tests for validation handling.""" + +# Django imports +from django import forms +from django.contrib import admin + +from django_admin_reversefields.mixins import ( + ReverseRelationAdminMixin, + ReverseRelationConfig, +) + +from ..models import Company, Department, Project + +# Test imports +from ..shared_test_base import BaseAdminMixinTestCase +from .utils import create_parameterized_admin + + +class ParameterizedValidationTests(BaseAdminMixinTestCase): + """Test validation scenarios work consistently in both bulk and non-bulk modes.""" + + admin_class = admin.ModelAdmin + + def setUp(self): + super().setUp() + self.dept_engineering = Department.objects.create(name="Engineering", budget=500000.00) + self.dept_marketing = Department.objects.create(name="Marketing", budget=200000.00) + + def test_required_field_validation_both_modes(self): + """Test required field validation works consistently in both modes.""" + # Test both bulk modes + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + # Create admin with required field + class TestAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_relations = { + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + required=True, + ) + } + + admin_instance = TestAdmin(Company, self.site) + + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, self.company) + + # Test with empty required field + form = form_cls( + {"name": self.company.name, "department_binding": ""}, instance=self.company + ) + + # Form should be invalid for required field + self.assertFalse( + form.is_valid(), + f"Form should be invalid for empty required field with bulk={bulk_enabled}", + ) + self.assertIn( + "department_binding", + form.errors, + f"department_binding should have validation error with bulk={bulk_enabled}", + ) + + def test_invalid_pk_validation_both_modes(self): + """Test invalid primary key validation works consistently in both modes.""" + # Test both bulk modes + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, self.company) + + # Test with invalid primary key + form = form_cls( + {"name": self.company.name, "department_binding": 99999}, instance=self.company + ) + + # Form should be invalid for non-existent PK + self.assertFalse( + form.is_valid(), + f"Form should be invalid for non-existent PK with bulk={bulk_enabled}", + ) + + def test_invalid_selection_validation_both_modes(self): + """Test invalid selection validation works consistently in both modes.""" + # Create test data + project_1 = Project.objects.create(name="Project 1") + + # Test both bulk modes + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + admin_instance = create_parameterized_admin(bulk_enabled=bulk_enabled) + + request = self.factory.post("/") + form_cls = admin_instance.get_form(request, self.company) + + # Test with mixed valid and invalid PKs in multi-select + form = form_cls( + {"name": self.company.name, "assigned_projects": [project_1.pk, 99999]}, + instance=self.company, + ) + + # Form should be invalid for mixed valid/invalid PKs + self.assertFalse( + form.is_valid(), + f"Form should be invalid for mixed valid/invalid PKs with bulk={bulk_enabled}", + ) + + def test_validation_hook_both_modes(self): + """ + Test validation hook behavior with bulk=False following documented examples. + """ + for bulk_enabled in [False, True]: + with self.subTest(bulk_enabled=bulk_enabled): + + def department_validation(instance, selection, request): + """Custom validation following documented signature.""" + if selection and hasattr(selection, "budget"): + if selection.budget and selection.budget > 300000: + raise forms.ValidationError( + "Department budget too high for this company" + ) + + class CompanyAdmin(ReverseRelationAdminMixin, self.admin_class): + reverse_relations = { + "primary_department": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + clean=department_validation, + ) + } + + request = self.factory.post("/") + admin_inst = CompanyAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Test with low-budget department (should pass validation) + form_valid = form_cls( + {"name": self.company.name, "primary_department": self.dept_marketing.pk}, + instance=self.company, + ) + self.assertTrue(form_valid.is_valid()) + + # Test with high-budget department (should fail validation) + form_invalid = form_cls( + {"name": self.company.name, "primary_department": self.dept_engineering.pk}, + instance=self.company, + ) + self.assertFalse(form_invalid.is_valid()) + self.assertIn("primary_department", form_invalid.errors) + self.assertIn( + "budget too high", form_invalid.errors["primary_department"][0].lower() + ) diff --git a/tests/parameterized/utils.py b/tests/parameterized/utils.py new file mode 100644 index 0000000..08e3880 --- /dev/null +++ b/tests/parameterized/utils.py @@ -0,0 +1,33 @@ +# Django imports +from django.contrib import admin + +# Project imports +from django_admin_reversefields.mixins import ( + ReverseRelationAdminMixin, + ReverseRelationConfig, +) + +# Test imports +from ..models import Company, Department, Project + + +def create_parameterized_admin(bulk_enabled): + """Factory to create a TestAdmin class with parameterized bulk settings.""" + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=False, + bulk=bulk_enabled, + ), + "assigned_projects": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=True, + bulk=bulk_enabled, + ), + } + + return TestAdmin(Company, None) diff --git a/tests/shared_test_base.py b/tests/shared_test_base.py index 2c6b8f2..2f540f9 100644 --- a/tests/shared_test_base.py +++ b/tests/shared_test_base.py @@ -10,7 +10,7 @@ ReverseRelationConfig, ) -from .models import Extension, Service, Site, UniqueExtension +from .models import Company, Department, Employee, Project class DummySite(AdminSite): @@ -19,122 +19,121 @@ class DummySite(AdminSite): class BaseAdminMixinTestCase(TestCase): """Base test case with common setup and utilities for admin mixin tests.""" - + def setUp(self): """Set up common test fixtures.""" self.site = DummySite() self.factory = RequestFactory() - self.service = Service.objects.create(name="test-service") - - def create_test_extensions(self, count=3, service=None): - """Create test Extension objects. - + self.company = Company.objects.create(name="test-company") + + def create_test_departments(self, count=3, company=None): + """Create test Department objects. + Args: - count: Number of extensions to create - service: Service to bind extensions to (optional) - + count: Number of departments to create + company: Company to bind departments to (optional) + Returns: - List of created Extension objects + List of created Department objects """ - extensions = [] + departments = [] for i in range(count): - ext = Extension.objects.create( - number=f"100{i + 1}", - service=service - ) - extensions.append(ext) - return extensions - - def create_test_sites(self, count=2, service=None): - """Create test Site objects. - + dept = Department.objects.create(name=f"Department {i + 1}", company=company) + departments.append(dept) + return departments + + def create_test_projects(self, count=2, company=None): + """Create test Project objects. + Args: - count: Number of sites to create - service: Service to bind sites to (optional) - + count: Number of projects to create + company: Company to bind projects to (optional) + Returns: - List of created Site objects + List of created Project objects """ - sites = [] + projects = [] for i in range(count): - site = Site.objects.create( - name=f"Site {chr(65 + i)}", # Site A, Site B, etc. - service=service + project = Project.objects.create( + name=f"Project {chr(65 + i)}", # Project A, Project B, etc. + company=company, ) - sites.append(site) - return sites - - def create_test_services(self, count=1): - """Create test Service objects. - + projects.append(project) + return projects + + def create_test_companies(self, count=1): + """Create test Company objects. + Args: - count: Number of services to create - + count: Number of companies to create + Returns: - List of created Service objects + List of created Company objects """ - services = [] + companies = [] for i in range(count): - service = Service.objects.create(name=f"service-{i + 1}") - services.append(service) - return services - - def create_test_unique_extensions(self, count=2, service=None): - """Create test UniqueExtension objects. - + company = Company.objects.create(name=f"company-{i + 1}") + companies.append(company) + return companies + + def create_test_employees(self, count=2, department=None): + """Create test Employee objects. + Args: - count: Number of unique extensions to create - service: Service to bind unique extensions to (optional) - + count: Number of employees to create + department: Department to bind employees to (optional) + Returns: - List of created UniqueExtension objects + List of created Employee objects """ - unique_extensions = [] + employees = [] for i in range(count): - unique_ext = UniqueExtension.objects.create( - number=f"200{i + 1}", - service=service + employee = Employee.objects.create( + name=f"Employee {i + 1}", + email=f"employee{i + 1}@example.com", + department=department, ) - unique_extensions.append(unique_ext) - return unique_extensions - + employees.append(employee) + return employees + def create_parameterized_admin(self, bulk_enabled=False, **config_overrides): """Create admin with configurable bulk settings for parameterized tests. - + Args: bulk_enabled: Whether to enable bulk operations **config_overrides: Additional configuration overrides for reverse relations - + Returns: Admin class instance configured for testing """ - class ParameterizedServiceAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + + class ParameterizedCompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - label="Site", + "department_binding": ReverseRelationConfig( + model=Department, + fk_field="company", + label="Department", bulk=bulk_enabled, - **config_overrides.get("site_binding", {}) + **config_overrides.get("department_binding", {}), ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - label="Extensions", + "assigned_projects": ReverseRelationConfig( + model=Project, + fk_field="company", + label="Projects", multiple=True, - ordering=("number",), + ordering=("name",), bulk=bulk_enabled, - **config_overrides.get("assigned_extensions", {}) + **config_overrides.get("assigned_projects", {}), ), } - - fieldsets = (("Binding", {"fields": ("site_binding", "assigned_extensions")}),) - - return ParameterizedServiceAdmin(Service, self.site) - + + fieldsets = (("Binding", {"fields": ("department_binding", "assigned_projects")}),) + + return ParameterizedCompanyAdmin(Company, self.site) + def assert_widget_compatibility(self, widget_class, field_name, admin_instance=None): """Assert that a widget works correctly with reverse relation fields. - + Args: widget_class: Widget class to test field_name: Name of the reverse relation field @@ -142,96 +141,107 @@ def assert_widget_compatibility(self, widget_class, field_name, admin_instance=N """ if admin_instance is None: admin_instance = self.create_parameterized_admin() - + request = self.factory.get("/") - form_cls = admin_instance.get_form(request, self.service) - form = form_cls(instance=self.service) - + form_cls = admin_instance.get_form(request, self.company) + form = form_cls(instance=self.company) + # Assert field exists self.assertIn(field_name, form.fields) - + # Assert widget is of expected type field = form.fields[field_name] self.assertIsInstance(field.widget, widget_class) - + # Assert widget renders without error widget_html = field.widget.render(field_name, None) self.assertIsInstance(widget_html, str) self.assertGreater(len(widget_html), 0) - + def create_permission_test_scenario(self, policy_type="allow_all"): """Create standardized permission test scenarios. - + Args: policy_type: Type of permission scenario to create - "allow_all": Always allow access - - "deny_all": Always deny access + - "deny_all": Always deny access - "staff_only": Allow only staff users - "callable": Function-based permission - + Returns: Dictionary with permission policy and test users """ # Create test users - regular_user = User.objects.create_user( - username="regular", password="test", is_staff=False - ) - staff_user = User.objects.create_user( - username="staff", password="test", is_staff=True - ) - + regular_user = User.objects.create_user(username="regular", password="test", is_staff=False) + staff_user = User.objects.create_user(username="staff", password="test", is_staff=True) + if policy_type == "allow_all": - policy = lambda request, obj, config, selection: True + + def policy(request, obj, config, selection): + return True elif policy_type == "deny_all": - policy = lambda request, obj, config, selection: False + + def policy(request, obj, config, selection): + return False elif policy_type == "staff_only": - policy = lambda request, obj, config, selection: getattr(request.user, "is_staff", False) + + def policy(request, obj, config, selection): + return getattr(request.user, "is_staff", False) elif policy_type == "callable": - def custom_policy(request, obj, config, selection): + + def policy(request, obj, config, selection): """Custom permission policy for testing.""" - return hasattr(request.user, "username") and request.user.username.startswith("staff") - policy = custom_policy + return hasattr(request.user, "username") and request.user.username.startswith( + "staff" + ) else: raise ValueError(f"Unknown policy_type: {policy_type}") - + return { "policy": policy, "regular_user": regular_user, "staff_user": staff_user, } - - def create_large_dataset(self, count=100, model_type="extensions"): + + def create_large_dataset(self, count=100, model_type="departments"): """Create large datasets for performance and edge case testing. - + Args: count: Number of objects to create - model_type: Type of objects to create ("extensions", "sites", "services") - + model_type: Type of objects to create ("departments", "projects", "companies", + "employees") + Returns: List of created objects """ objects = [] - - if model_type == "extensions": + + if model_type == "departments": for i in range(count): - ext = Extension.objects.create(number=f"bulk-{i:04d}") - objects.append(ext) - elif model_type == "sites": + dept = Department.objects.create(name=f"Bulk Department {i:04d}") + objects.append(dept) + elif model_type == "projects": for i in range(count): - site = Site.objects.create(name=f"Bulk Site {i:04d}") - objects.append(site) - elif model_type == "services": + project = Project.objects.create(name=f"Bulk Project {i:04d}") + objects.append(project) + elif model_type == "companies": for i in range(count): - service = Service.objects.create(name=f"bulk-service-{i:04d}") - objects.append(service) + company = Company.objects.create(name=f"bulk-company-{i:04d}") + objects.append(company) + elif model_type == "employees": + for i in range(count): + employee = Employee.objects.create( + name=f"Bulk Employee {i:04d}", email=f"bulk{i:04d}@example.com" + ) + objects.append(employee) else: raise ValueError(f"Unknown model_type: {model_type}") - + return objects - + def create_edge_case_data(self, scenario="empty_queryset"): """Create various edge case data scenarios for testing. - + Args: scenario: Type of edge case to create - "empty_queryset": No related objects available @@ -239,138 +249,138 @@ def create_edge_case_data(self, scenario="empty_queryset"): - "max_items": Maximum reasonable number of items - "invalid_data": Data that should cause validation errors - "complex_relationships": Complex model relationship scenarios - + Returns: Dictionary with scenario data and metadata """ if scenario == "empty_queryset": # Clear all existing objects - Extension.objects.all().delete() - Site.objects.all().delete() - return {"extensions": [], "sites": [], "description": "No objects available"} - + Department.objects.all().delete() + Project.objects.all().delete() + return {"departments": [], "projects": [], "description": "No objects available"} + elif scenario == "single_item": # Create exactly one of each type - Extension.objects.all().delete() - Site.objects.all().delete() - ext = Extension.objects.create(number="single-ext") - site = Site.objects.create(name="Single Site") + Department.objects.all().delete() + Project.objects.all().delete() + dept = Department.objects.create(name="Single Department") + project = Project.objects.create(name="Single Project") return { - "extensions": [ext], - "sites": [site], - "description": "Single item available" + "departments": [dept], + "projects": [project], + "description": "Single item available", } - + elif scenario == "max_items": # Create a large but reasonable number of items - extensions = self.create_large_dataset(50, "extensions") - sites = self.create_large_dataset(25, "sites") + departments = self.create_large_dataset(50, "departments") + projects = self.create_large_dataset(25, "projects") return { - "extensions": extensions, - "sites": sites, - "description": "Maximum reasonable items" + "departments": departments, + "projects": projects, + "description": "Maximum reasonable items", } - + elif scenario == "invalid_data": # Create data that should cause validation issues return { "invalid_pks": [99999, -1, "invalid"], "empty_strings": ["", None], - "description": "Invalid data for validation testing" + "description": "Invalid data for validation testing", } - + elif scenario == "complex_relationships": # Create complex relationship scenarios - service1 = Service.objects.create(name="complex-service-1") - service2 = Service.objects.create(name="complex-service-2") - - # Extensions bound to different services - ext1 = Extension.objects.create(number="complex-1", service=service1) - ext2 = Extension.objects.create(number="complex-2", service=service2) - ext3 = Extension.objects.create(number="complex-3") # Unbound - - # Sites with mixed binding states - site1 = Site.objects.create(name="Complex Site 1", service=service1) - site2 = Site.objects.create(name="Complex Site 2") # Unbound - + company1 = Company.objects.create(name="complex-company-1") + company2 = Company.objects.create(name="complex-company-2") + + # Departments bound to different companies + dept1 = Department.objects.create(name="Complex Dept 1", company=company1) + dept2 = Department.objects.create(name="Complex Dept 2", company=company2) + dept3 = Department.objects.create(name="Complex Dept 3") # Unbound + + # Projects with mixed binding states + project1 = Project.objects.create(name="Complex Project 1", company=company1) + project2 = Project.objects.create(name="Complex Project 2") # Unbound + return { - "services": [service1, service2], - "extensions": [ext1, ext2, ext3], - "sites": [site1, site2], - "bound_extensions": [ext1, ext2], - "unbound_extensions": [ext3], - "bound_sites": [site1], - "unbound_sites": [site2], - "description": "Complex relationship scenarios" + "companies": [company1, company2], + "departments": [dept1, dept2, dept3], + "projects": [project1, project2], + "bound_departments": [dept1, dept2], + "unbound_departments": [dept3], + "bound_projects": [project1], + "unbound_projects": [project2], + "description": "Complex relationship scenarios", } - + else: raise ValueError(f"Unknown scenario: {scenario}") - + def create_widget_test_data(self, widget_type="select"): """Create test data optimized for widget compatibility testing. - + Args: widget_type: Type of widget being tested - "select": Single select widget - "radio": Radio select widget - "multiple": Multiple select widget - "checkbox": Checkbox multiple select widget - + Returns: Dictionary with test data and expected behaviors """ # Create base test data - extensions = self.create_test_extensions(5) - sites = self.create_test_sites(3) - + departments = self.create_test_departments(5) + projects = self.create_test_projects(3) + if widget_type in ["select", "radio"]: return { - "extensions": extensions, - "sites": sites, + "departments": departments, + "projects": projects, "expected_single_select": True, "expected_multiple_select": False, - "test_selections": [extensions[0].pk, sites[0].pk], - "description": f"Data for {widget_type} widget testing" + "test_selections": [departments[0].pk, projects[0].pk], + "description": f"Data for {widget_type} widget testing", } - + elif widget_type in ["multiple", "checkbox"]: return { - "extensions": extensions, - "sites": sites, + "departments": departments, + "projects": projects, "expected_single_select": False, "expected_multiple_select": True, "test_selections": [ - [extensions[0].pk, extensions[1].pk], - [sites[0].pk, sites[1].pk] + [departments[0].pk, departments[1].pk], + [projects[0].pk, projects[1].pk], ], - "description": f"Data for {widget_type} widget testing" + "description": f"Data for {widget_type} widget testing", } - + else: raise ValueError(f"Unknown widget_type: {widget_type}") - + def create_validation_test_scenarios(self): """Create comprehensive validation test scenarios. - + Returns: Dictionary with various validation scenarios """ # Create base data - service = Service.objects.create(name="validation-service") - extensions = self.create_test_extensions(3) - sites = self.create_test_sites(2) - + company = Company.objects.create(name="validation-company") + departments = self.create_test_departments(3) + projects = self.create_test_projects(2) + return { - "valid_single_selection": sites[0].pk, - "valid_multiple_selection": [extensions[0].pk, extensions[1].pk], + "valid_single_selection": projects[0].pk, + "valid_multiple_selection": [departments[0].pk, departments[1].pk], "invalid_pk": 99999, "invalid_type": "not-a-number", "empty_selection": None, "empty_multiple_selection": [], - "mixed_valid_invalid": [extensions[0].pk, 99999], - "service": service, - "extensions": extensions, - "sites": sites, - "description": "Comprehensive validation scenarios" - } \ No newline at end of file + "mixed_valid_invalid": [departments[0].pk, 99999], + "company": company, + "departments": departments, + "projects": projects, + "description": "Comprehensive validation scenarios", + } diff --git a/tests/test_base_edge_cases.py b/tests/test_base_edge_cases.py deleted file mode 100644 index 2130fbd..0000000 --- a/tests/test_base_edge_cases.py +++ /dev/null @@ -1,910 +0,0 @@ -"""Test suite for base operation edge cases and boundary conditions. - -This module tests boundary conditions, unusual scenarios, and edge cases for base -(non-bulk) operations, focusing on empty querysets, large datasets, model lifecycle -scenarios, and complex relationship handling. Base operations use the default -bulk=False setting and process items individually. -""" - -# Django imports -from django import forms -from django.contrib import admin -from django.db import transaction - -# Project imports -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -# Test imports -from .models import Extension, Service, Site, UniqueExtension -from .shared_test_base import BaseAdminMixinTestCase, DummySite - - -class BaseBoundaryConditionTests(BaseAdminMixinTestCase): - """Test suite for boundary conditions with empty sets and large datasets.""" - - def test_base_operation_empty_queryset_handling(self): - """Test base operations with completely empty querysets.""" - # Clear all existing data - Extension.objects.all().delete() - Site.objects.all().delete() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls(instance=self.service) - - # Fields should exist but have no choices - self.assertIn("site_binding", form.fields) - self.assertIn("assigned_extensions", form.fields) - - # Querysets should be empty - site_field = form.fields["site_binding"] - ext_field = form.fields["assigned_extensions"] - - if hasattr(site_field, "queryset"): - self.assertEqual(site_field.queryset.count(), 0) - if hasattr(ext_field, "queryset"): - self.assertEqual(ext_field.queryset.count(), 0) - - # Form should be valid with empty selections - form_data = {"name": self.service.name, "site_binding": "", "assigned_extensions": []} - form_with_data = form_cls(form_data, instance=self.service) - self.assertTrue(form_with_data.is_valid()) - - def test_base_operation_single_item_selection_edge_cases(self): - """Test base operations with exactly one item available.""" - # Clear existing data and create exactly one of each type - Extension.objects.all().delete() - Site.objects.all().delete() - - single_site = Site.objects.create(name="Only Site") - single_ext = Extension.objects.create(number="1001") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test selecting the single available item - form = form_cls( - { - "name": self.service.name, - "site_binding": single_site.pk, - "assigned_extensions": [single_ext.pk], - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify bindings were created - single_site.refresh_from_db() - single_ext.refresh_from_db() - self.assertEqual(single_site.service, saved_service) - self.assertEqual(single_ext.service, saved_service) - - # Test deselecting (should unbind) - form_deselect = form_cls( - {"name": self.service.name, "site_binding": "", "assigned_extensions": []}, - instance=self.service, - ) - - self.assertTrue(form_deselect.is_valid()) - form_deselect.save() - - # Verify items were unbound - single_site.refresh_from_db() - single_ext.refresh_from_db() - self.assertIsNone(single_site.service) - self.assertIsNone(single_ext.service) - - def test_base_operation_large_dataset_performance(self): - """Test base operations with large datasets.""" - # Create a large dataset - large_extensions = self.create_large_dataset(100, "extensions") - large_sites = self.create_large_dataset(50, "sites") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test form creation with large dataset (should not timeout) - form = form_cls(instance=self.service) - self.assertIn("site_binding", form.fields) - self.assertIn("assigned_extensions", form.fields) - - # Test selecting multiple items from large dataset - selected_extensions = [ext.pk for ext in large_extensions[:10]] # Select first 10 - selected_site = large_sites[0].pk - - form_with_selection = form_cls( - { - "name": self.service.name, - "site_binding": selected_site, - "assigned_extensions": selected_extensions, - }, - instance=self.service, - ) - - self.assertTrue(form_with_selection.is_valid()) - saved_service = form_with_selection.save() - - # Verify correct number of bindings - bound_extensions = Extension.objects.filter(service=saved_service) - self.assertEqual(bound_extensions.count(), 10) - - bound_site = Site.objects.get(service=saved_service) - self.assertEqual(bound_site.pk, selected_site) - - def test_base_operation_maximum_selection_limits(self): - """Test base operations at maximum reasonable selection limits.""" - # Create test data - extensions = self.create_test_extensions(20) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test selecting all available items - all_extension_pks = [ext.pk for ext in extensions] - form = form_cls( - {"name": self.service.name, "assigned_extensions": all_extension_pks}, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify all items were bound - bound_count = Extension.objects.filter(service=saved_service).count() - self.assertEqual(bound_count, len(extensions)) - - def test_base_operation_filtered_queryset_edge_cases(self): - """Test base operations with heavily filtered querysets.""" - # Create test data with specific patterns - extensions = [] - for i in range(10): - ext = Extension.objects.create(number=f"100{i}") - extensions.append(ext) - - # Create admin with filtering that excludes most items - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - limit_choices_to=lambda qs, instance, request: qs.filter( - number__endswith="5" - ), # Only numbers ending in 5 - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls(instance=self.service) - - # Should only have one choice (1005) - ext_field = form.fields["assigned_extensions"] - if hasattr(ext_field, "queryset"): - filtered_count = ext_field.queryset.count() - self.assertEqual(filtered_count, 1) - - # Test selecting the filtered item - filtered_ext = Extension.objects.get(number="1005") - form_with_selection = form_cls( - {"name": self.service.name, "assigned_extensions": [filtered_ext.pk]}, - instance=self.service, - ) - - self.assertTrue(form_with_selection.is_valid()) - saved_service = form_with_selection.save() - - # Verify binding was created - filtered_ext.refresh_from_db() - self.assertEqual(filtered_ext.service, saved_service) - - def test_base_operation_empty_selection_after_filtering(self): - """Test base operations when filtering results in no available choices.""" - # Create test data - self.create_test_extensions(5) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - limit_choices_to=lambda qs, instance, request: qs.filter( - number="nonexistent" - ), # Filter that matches nothing - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls(instance=self.service) - - # Should have no choices available - ext_field = form.fields["assigned_extensions"] - if hasattr(ext_field, "queryset"): - self.assertEqual(ext_field.queryset.count(), 0) - - # Form should be valid with empty selection - form_with_empty = form_cls( - {"name": self.service.name, "assigned_extensions": []}, instance=self.service - ) - self.assertTrue(form_with_empty.is_valid()) - - -class BaseModelStateTests(BaseAdminMixinTestCase): - """Test suite for different model lifecycle scenarios.""" - - def test_base_operation_with_unsaved_model_instance(self): - """Test base operations with unsaved model instances.""" - # Create an unsaved service instance - unsaved_service = Service(name="unsaved-service") - - # Create test data - site_a = Site.objects.create(name="Site A") - ext_a = Extension.objects.create(number="1001") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, unsaved_service) - - # Form should be created successfully - form = form_cls( - { - "name": unsaved_service.name, - "site_binding": site_a.pk, - "assigned_extensions": [ext_a.pk], - }, - instance=unsaved_service, - ) - - self.assertTrue(form.is_valid()) - - # Save should create the service and establish bindings - saved_service = form.save() - self.assertIsNotNone(saved_service.pk) - - # Verify bindings were created - site_a.refresh_from_db() - ext_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - self.assertEqual(ext_a.service, saved_service) - - def test_base_operation_model_creation_vs_update_scenarios(self): - """Test base operations in both model creation and update scenarios.""" - # Create test data - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B") - ext_a = Extension.objects.create(number="1001") - ext_b = Extension.objects.create(number="1002") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - - # Test creation scenario - new_service = Service(name="new-service") - form_cls_create = admin_inst.get_form(request, new_service) - form_create = form_cls_create( - { - "name": new_service.name, - "site_binding": site_a.pk, - "assigned_extensions": [ext_a.pk], - }, - instance=new_service, - ) - - self.assertTrue(form_create.is_valid()) - created_service = form_create.save() - - # Verify creation bindings - site_a.refresh_from_db() - ext_a.refresh_from_db() - self.assertEqual(site_a.service, created_service) - self.assertEqual(ext_a.service, created_service) - - # Test update scenario - form_cls_update = admin_inst.get_form(request, created_service) - form_update = form_cls_update( - { - "name": created_service.name, - "site_binding": site_b.pk, - "assigned_extensions": [ext_b.pk], - }, - instance=created_service, - ) - - self.assertTrue(form_update.is_valid()) - updated_service = form_update.save() - - # Verify update bindings (old bindings should be removed) - site_a.refresh_from_db() - site_b.refresh_from_db() - ext_a.refresh_from_db() - ext_b.refresh_from_db() - - self.assertIsNone(site_a.service) # Unbound - self.assertEqual(site_b.service, updated_service) # Bound - self.assertIsNone(ext_a.service) # Unbound - self.assertEqual(ext_b.service, updated_service) # Bound - - def test_base_operation_with_pre_existing_bindings(self): - """Test base operations when objects already have existing bindings.""" - # Create services and pre-bind some objects - other_service = Service.objects.create(name="other-service") - - site_a = Site.objects.create(name="Site A", service=other_service) - site_b = Site.objects.create(name="Site B") # Unbound - ext_a = Extension.objects.create(number="1001", service=other_service) - ext_b = Extension.objects.create(number="1002") # Unbound - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Select objects that are bound to other services - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, # Currently bound to other_service - "assigned_extensions": [ext_a.pk, ext_b.pk], # ext_a bound, ext_b unbound - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify objects were transferred to our service - site_a.refresh_from_db() - ext_a.refresh_from_db() - ext_b.refresh_from_db() - - self.assertEqual(site_a.service, saved_service) - self.assertEqual(ext_a.service, saved_service) - self.assertEqual(ext_b.service, saved_service) - - # Verify other_service lost its bindings - other_service.refresh_from_db() - self.assertEqual(Site.objects.filter(service=other_service).count(), 0) - self.assertEqual(Extension.objects.filter(service=other_service).count(), 0) - - def test_base_operation_model_deletion_edge_cases(self): - """Test base operations when related models are deleted during processing.""" - # Create test data - site_a = Site.objects.create(name="Site A") - ext_a = Extension.objects.create(number="1001") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Create form with valid selections - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - "assigned_extensions": [ext_a.pk], - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - - # Delete one of the selected objects before saving - deleted_ext_pk = ext_a.pk - ext_a.delete() - - # Save should handle the missing object gracefully - # The exact behavior depends on implementation, but it shouldn't crash - from django.db.utils import DatabaseError - try: - saved_service = form.save() - # If save succeeds, verify remaining bindings - site_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - # Verify the deleted extension is not bound - self.assertEqual(Extension.objects.filter(pk=deleted_ext_pk).count(), 0) - except (Extension.DoesNotExist, DatabaseError): - # If save fails due to missing object or database error, that's acceptable behavior - # The important thing is that it doesn't crash unexpectedly - pass - - def test_base_operation_concurrent_model_modifications(self): - """Test base operations with concurrent model modifications.""" - # Create test data - site_a = Site.objects.create(name="Site A") - ext_a = Extension.objects.create(number="1001") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Create form - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - "assigned_extensions": [ext_a.pk], - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - - # Simulate concurrent modification: another process binds the objects - concurrent_service = Service.objects.create(name="concurrent-service") - site_a.service = concurrent_service - site_a.save() - ext_a.service = concurrent_service - ext_a.save() - - # Our form save should still work (unbind from concurrent, bind to ours) - saved_service = form.save() - - # Verify final state - site_a.refresh_from_db() - ext_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - self.assertEqual(ext_a.service, saved_service) - - def test_base_operation_transaction_rollback_scenarios(self): - """Test base operations with transaction rollback scenarios.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "unique_binding": ReverseRelationConfig( - model=UniqueExtension, - fk_field="service", - multiple=False, - ), - } - - # Create test data - unique_ext_a = UniqueExtension.objects.create(number="1001") - unique_ext_b = UniqueExtension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Create a form that should succeed - form = form_cls( - {"name": self.service.name, "unique_binding": unique_ext_a.pk}, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - - # Use transaction to test rollback behavior - try: - with transaction.atomic(): - saved_service = form.save() - - # Verify binding was created - unique_ext_a.refresh_from_db() - self.assertEqual(unique_ext_a.service, saved_service) - - # Force a rollback by raising an exception - raise RuntimeError("Force rollback") - - except RuntimeError: - pass # Expected - - # After rollback, binding should not exist - unique_ext_a.refresh_from_db() - self.assertIsNone(unique_ext_a.service) - - -class BaseComplexRelationshipTests(BaseAdminMixinTestCase): - """Test suite for complex model relationship scenarios.""" - - def test_base_operation_multiple_services_complex_bindings(self): - """Test base operations with multiple services and complex binding patterns.""" - # Create multiple services and objects - service_a = Service.objects.create(name="service-a") - service_b = Service.objects.create(name="service-b") - - # Create objects with mixed binding states - site_1 = Site.objects.create(name="Site 1", service=service_a) - site_2 = Site.objects.create(name="Site 2") # Unbound - site_3 = Site.objects.create(name="Site 3", service=service_b) - - ext_1 = Extension.objects.create(number="1001", service=service_a) - ext_2 = Extension.objects.create(number="1002", service=service_b) - ext_3 = Extension.objects.create(number="1003") # Unbound - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Select objects from different services and unbound objects - form = form_cls( - { - "name": self.service.name, - "site_binding": site_3.pk, # From service_b - "assigned_extensions": [ext_1.pk, ext_2.pk, ext_3.pk], # Mixed sources - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify all objects were transferred to our service - site_3.refresh_from_db() - ext_1.refresh_from_db() - ext_2.refresh_from_db() - ext_3.refresh_from_db() - - self.assertEqual(site_3.service, saved_service) - self.assertEqual(ext_1.service, saved_service) - self.assertEqual(ext_2.service, saved_service) - self.assertEqual(ext_3.service, saved_service) - - # Verify other services lost their bindings - self.assertEqual(Site.objects.filter(service=service_a).count(), 1) # site_1 remains - self.assertEqual(Site.objects.filter(service=service_b).count(), 0) # site_3 moved - self.assertEqual(Extension.objects.filter(service=service_a).count(), 0) # ext_1 moved - self.assertEqual(Extension.objects.filter(service=service_b).count(), 0) # ext_2 moved - - def test_base_operation_circular_relationship_scenarios(self): - """Test base operations with potential circular relationship scenarios.""" - # Create a complex scenario where services could reference each other indirectly - service_a = Service.objects.create(name="service-a") - service_b = Service.objects.create(name="service-b") - - # Create sites that reference different services - site_a = Site.objects.create(name="Site A", service=service_a) - site_b = Site.objects.create(name="Site B", service=service_b) - - # Create extensions that reference different services - ext_a = Extension.objects.create(number="1001", service=service_a) - ext_b = Extension.objects.create(number="1002", service=service_b) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - - # Test moving objects from service_a to service_b - form_cls_b = admin_inst.get_form(request, service_b) - form_b = form_cls_b( - { - "name": service_b.name, - "site_binding": site_a.pk, # Move from service_a - "assigned_extensions": [ext_a.pk, ext_b.pk], # Keep ext_b, add ext_a - }, - instance=service_b, - ) - - self.assertTrue(form_b.is_valid()) - form_b.save() - - # Verify transfers - site_a.refresh_from_db() - ext_a.refresh_from_db() - ext_b.refresh_from_db() - - self.assertEqual(site_a.service, service_b) - self.assertEqual(ext_a.service, service_b) - self.assertEqual(ext_b.service, service_b) - - # Now test moving objects back to service_a - form_cls_a = admin_inst.get_form(request, service_a) - form_a = form_cls_a( - { - "name": service_a.name, - "site_binding": site_b.pk, # Move from service_b - "assigned_extensions": [ext_a.pk], # Move back ext_a - }, - instance=service_a, - ) - - self.assertTrue(form_a.is_valid()) - form_a.save() - - # Verify final state - site_a.refresh_from_db() - site_b.refresh_from_db() - ext_a.refresh_from_db() - ext_b.refresh_from_db() - - self.assertEqual(site_a.service, service_b) # Still with service_b - self.assertEqual(site_b.service, service_a) # Moved to service_a - self.assertEqual(ext_a.service, service_a) # Moved back to service_a - self.assertEqual(ext_b.service, service_b) # Remains with service_b - - def test_base_operation_mixed_relationship_types(self): - """Test base operations with mixed relationship types (ForeignKey and OneToOne).""" - # Create test data - site_a = Site.objects.create(name="Site A") - ext_a = Extension.objects.create(number="1001") - unique_ext_a = UniqueExtension.objects.create(number="2001") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, # Single ForeignKey - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, # Multiple ForeignKey - ), - "unique_binding": ReverseRelationConfig( - model=UniqueExtension, - fk_field="service", - multiple=False, # OneToOne relationship - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test binding all relationship types - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - "assigned_extensions": [ext_a.pk], - "unique_binding": unique_ext_a.pk, - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify all bindings were created - site_a.refresh_from_db() - ext_a.refresh_from_db() - unique_ext_a.refresh_from_db() - - self.assertEqual(site_a.service, saved_service) - self.assertEqual(ext_a.service, saved_service) - self.assertEqual(unique_ext_a.service, saved_service) - - # Test partial unbinding (keep unique, unbind others) - form_partial = form_cls( - { - "name": self.service.name, - "site_binding": "", - "assigned_extensions": [], - "unique_binding": unique_ext_a.pk, # Keep this one - }, - instance=self.service, - ) - - self.assertTrue(form_partial.is_valid()) - form_partial.save() - - # Verify partial unbinding - site_a.refresh_from_db() - ext_a.refresh_from_db() - unique_ext_a.refresh_from_db() - - self.assertIsNone(site_a.service) # Unbound - self.assertIsNone(ext_a.service) # Unbound - self.assertEqual(unique_ext_a.service, saved_service) # Still bound - - def test_base_operation_relationship_constraint_interactions(self): - """Test base operations with complex constraint interactions.""" - # Create services and unique extensions - service_a = Service.objects.create(name="service-a") - service_b = Service.objects.create(name="service-b") - - unique_ext_1 = UniqueExtension.objects.create(number="1001", service=service_a) - unique_ext_2 = UniqueExtension.objects.create(number="1002", service=service_b) - unique_ext_3 = UniqueExtension.objects.create(number="1003") # Unbound - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "unique_binding": ReverseRelationConfig( - model=UniqueExtension, - fk_field="service", - multiple=False, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test transferring unique extension from another service - form = form_cls( - {"name": self.service.name, "unique_binding": unique_ext_1.pk}, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify transfer (should unbind from service_a, bind to our service) - unique_ext_1.refresh_from_db() - self.assertEqual(unique_ext_1.service, saved_service) - - # Verify service_a lost its unique extension - service_a.refresh_from_db() - with self.assertRaises(UniqueExtension.DoesNotExist): - _ = service_a.unique_extension - - # Test switching to a different unique extension - form_switch = form_cls( - {"name": self.service.name, "unique_binding": unique_ext_2.pk}, - instance=self.service, - ) - - self.assertTrue(form_switch.is_valid()) - form_switch.save() - - # Verify switch (unique_ext_1 should be unbound, unique_ext_2 should be bound) - unique_ext_1.refresh_from_db() - unique_ext_2.refresh_from_db() - - self.assertIsNone(unique_ext_1.service) # Unbound - self.assertEqual(unique_ext_2.service, saved_service) # Bound - - # Verify service_b lost its unique extension - service_b.refresh_from_db() - with self.assertRaises(UniqueExtension.DoesNotExist): - _ = service_b.unique_extension \ No newline at end of file diff --git a/tests/test_base_permissions.py b/tests/test_base_permissions.py deleted file mode 100644 index 5dda472..0000000 --- a/tests/test_base_permissions.py +++ /dev/null @@ -1,1384 +0,0 @@ -"""Test suite for base operation permission handling and policies. - -This module tests permission functionality for base (non-bulk) operations, -focusing on policy enforcement, callable permissions, and permission modes. -Base operations use the default bulk=False setting and process items individually. -""" - -# Django imports -from django.contrib import admin -from django.contrib.auth.models import User - -# Project imports -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -# Test imports -from .models import Extension, Service, Site -from .shared_test_base import BaseAdminMixinTestCase, DummySite - - -class BasePermissionPolicyTests(BaseAdminMixinTestCase): - """Test suite for base operation permission policy enforcement.""" - - def test_base_operation_per_field_permission_callable_denies(self): - """Test per-field permission callable that denies access for base operations.""" - site_a = Site.objects.create(name="Site A") - - def deny_policy(request, obj, config, selection): - """Deny all access.""" - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=deny_policy, - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertFalse(form.is_valid()) - self.assertIn("permission", form.errors.get("site_binding", [""])[0].lower()) - - # Verify no change was persisted - site_a.refresh_from_db() - self.assertIsNone(site_a.service) - - def test_base_operation_per_field_permission_callable_allows(self): - """Test per-field permission callable that allows access for base operations.""" - site_a = Site.objects.create(name="Site A") - - def allow_policy(request, obj, config, selection): - """Allow all access.""" - return True - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=allow_policy, - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify the binding was created - site_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - - def test_base_operation_selection_based_permission(self): - """Test per-field permission based on object selection for base operations.""" - site_a = Site.objects.create(name="A") - site_b = Site.objects.create(name="B") - - class SelectivePolicy: - """Policy that allows only specific selections.""" - - def has_perm(self, request, obj, config, selection): - """Allow only selecting site with name 'B'.""" - if selection and getattr(selection, "name", None) == "B": - return True - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=SelectivePolicy(), - permission_denied_message="Not allowed for this selection", - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test denied selection (Site A) - form_denied = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - self.assertFalse(form_denied.is_valid()) - self.assertIn( - "not allowed for this selection", - form_denied.errors.get("site_binding", [""])[0].lower(), - ) - - # Verify no binding was created - site_a.refresh_from_db() - self.assertIsNone(site_a.service) - - # Test allowed selection (Site B) - form_allowed = form_cls( - {"name": self.service.name, "site_binding": site_b.pk}, - instance=self.service, - ) - self.assertTrue(form_allowed.is_valid()) - saved_service = form_allowed.save() - - # Verify the allowed binding was created - site_b.refresh_from_db() - self.assertEqual(site_b.service, saved_service) - - def test_base_operation_global_permission_policy(self): - """Test global reverse permission policy for base operations.""" - site_a = Site.objects.create(name="Site A") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - # Global policy: deny if selection name is "Site A" (like the working test) - reverse_permission_policy = staticmethod( - lambda request, obj, config, selection: ( - False - if (selection is not None and getattr(selection, "name", None) == "Site A") - else True - ) - ) - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertFalse(form.is_valid()) - self.assertIn("permission", form.errors.get("site_binding", [""])[0].lower()) - - # Verify no binding was created - site_a.refresh_from_db() - self.assertIsNone(site_a.service) - - def test_base_operation_policy_with_custom_message(self): - """Test policy object with custom error message for base operations.""" - site_a = Site.objects.create(name="Site A") - - class CustomMessagePolicy: - """Policy with custom error message.""" - - permission_denied_message = "Custom field-specific error message" - - def __call__(self, request, obj, config, selection): - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=CustomMessagePolicy(), - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertFalse(form.is_valid()) - self.assertIn( - "custom field-specific error message", form.errors.get("site_binding", [""])[0].lower() - ) - - -class BasePermissionCallableTests(BaseAdminMixinTestCase): - """Test suite for base operation function-based permissions.""" - - def test_base_operation_callable_permission_validates_correctly(self): - """Test that callable permissions validate correctly for base operations.""" - site_a = Site.objects.create(name="Site A") - - def always_allow_permission(request, obj, config, selection): - """Always allow access.""" - return True - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=always_allow_permission, - ) - } - - admin_inst = TestAdmin(Service, DummySite()) - request = self.factory.post("/") - - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # Should succeed with always-allow permission - self.assertTrue(form.is_valid()) - saved_service = form.save() - - site_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - - def test_base_operation_multiple_field_callable_permissions(self): - """Test multiple fields with different callable permissions for base operations.""" - site_a = Site.objects.create(name="Site A") - ext_1 = Extension.objects.create(number="1001") - - def allow_sites_only(request, obj, config, selection): - """Allow only Site model operations.""" - return config.model == Site - - def allow_extensions_only(request, obj, config, selection): - """Allow only Extension model operations.""" - return config.model == Extension - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=allow_sites_only, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - permission=allow_extensions_only, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Both fields should be allowed by their respective policies - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - "assigned_extensions": [ext_1.pk], - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify both bindings were created - site_a.refresh_from_db() - ext_1.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - self.assertEqual(ext_1.service, saved_service) - - def test_base_operation_callable_with_object_context(self): - """Test callable permissions that depend on the object being edited.""" - site_a = Site.objects.create(name="Site A") - - def object_dependent_permission(request, obj, config, selection): - """Permission that depends on the object being edited.""" - # Allow only if the service name contains 'allowed' - if obj and hasattr(obj, "name"): - return "allowed" in obj.name.lower() - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=object_dependent_permission, - ) - } - - allowed_service = Service.objects.create(name="allowed-service") - denied_service = Service.objects.create(name="denied-service") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - - # Test with allowed service - form_cls_allowed = admin_inst.get_form(request, allowed_service) - form_allowed = form_cls_allowed( - {"name": allowed_service.name, "site_binding": site_a.pk}, - instance=allowed_service, - ) - self.assertTrue(form_allowed.is_valid()) - saved_service = form_allowed.save() - - site_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - - # Reset site for next test - site_a.service = None - site_a.save() - - # Test with denied service - form_cls_denied = admin_inst.get_form(request, denied_service) - form_denied = form_cls_denied( - {"name": denied_service.name, "site_binding": site_a.pk}, - instance=denied_service, - ) - self.assertFalse(form_denied.is_valid()) - self.assertIn("site_binding", form_denied.errors) - - -class BasePermissionModeTests(BaseAdminMixinTestCase): - """Test suite for base operation hide vs disable permission behavior.""" - - def test_base_operation_disable_mode_disables_field(self): - """Test that disable mode disables field and ignores POST data for base operations.""" - - def deny_all_policy(request, obj, config, selection): - """Deny all access.""" - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permission_mode = "disable" - reverse_permissions_enabled = True - reverse_render_uses_field_policy = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=deny_all_policy, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # Field should be present but disabled - self.assertIn("site_binding", form.fields) - self.assertTrue(form.fields["site_binding"].disabled) - - # Form should be valid (disabled fields are ignored) - self.assertTrue(form.is_valid()) - form.save() - - # Verify no binding was created (POST data ignored) - site_a.refresh_from_db() - self.assertIsNone(site_a.service) - - def test_base_operation_hide_mode_removes_field(self): - """Test that hide mode removes field from form for base operations.""" - - def deny_all_policy(request, obj, config, selection): - """Deny all access.""" - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permission_mode = "hide" - reverse_permissions_enabled = True - reverse_render_uses_field_policy = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=deny_all_policy, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # Field should not be in the form (hidden) - self.assertNotIn("site_binding", form.fields) - - # Form should still be valid - self.assertTrue(form.is_valid()) - form.save() - - # Verify no binding was created (field was hidden) - site_a.refresh_from_db() - self.assertIsNone(site_a.service) - - def test_base_operation_disable_mode_with_required_field(self): - """Test disable mode with required field doesn't cause validation errors.""" - - def deny_all_policy(request, obj, config, selection): - """Deny all access.""" - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permission_mode = "disable" - reverse_permissions_enabled = True - reverse_render_uses_field_policy = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - required=True, # Required field - permission=deny_all_policy, - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": ""}, # Empty selection - instance=self.service, - ) - - # Field should be disabled and required=False to prevent validation errors - self.assertTrue(form.fields["site_binding"].disabled) - self.assertFalse(form.fields["site_binding"].required) - - # Form should be valid despite empty required field (disabled) - self.assertTrue(form.is_valid()) - - def test_base_operation_mixed_permission_modes(self): - """Test permission modes with mixed field permissions for base operations.""" - - def allow_extensions(request, obj, config, selection): - """Allow only Extension operations.""" - return config.model == Extension - - def deny_sites(request, obj, config, selection): - """Deny Site operations.""" - return config.model != Site - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permission_mode = "disable" - reverse_permissions_enabled = True - reverse_render_uses_field_policy = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=deny_sites, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - permission=allow_extensions, - ), - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls(instance=self.service) - - # Site field should be disabled, extensions field should be enabled - self.assertTrue(form.fields["site_binding"].disabled) - self.assertFalse(form.fields["assigned_extensions"].disabled) - - -class BasePermissionSystemIntegrityTests(BaseAdminMixinTestCase): - """Comprehensive test suite to verify base permission system integrity.""" - - def test_permission_callable_invocation_during_form_validation(self): - """Test that permission callables are properly invoked during form validation.""" - site_a = Site.objects.create(name="Site A") - - permission_calls = [] - - def track_permission_calls(request, obj, config, selection): - """Track permission calls and deny access.""" - permission_calls.append( - {"request": request, "obj": obj, "config": config, "selection": selection} - ) - return False # Deny access - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=track_permission_calls, - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # Validate the form - this should trigger permission validation - is_valid = form.is_valid() - - # Verify permission was called during validation - self.assertGreater(len(permission_calls), 0, "Permission callable should be invoked") - - # Verify form is invalid due to permission denial - self.assertFalse(is_valid, "Form should be invalid due to permission denial") - - # Verify proper error message - self.assertIn("site_binding", form.errors) - self.assertIn("permission", form.errors["site_binding"][0].lower()) - - def test_permission_denials_prevent_data_persistence(self): - """Test that permission denials properly prevent form submission and data persistence.""" - site_a = Site.objects.create(name="Site A") - - def deny_policy(request, obj, config, selection): - """Deny all access.""" - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=deny_policy, - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # Form should be invalid - self.assertFalse(form.is_valid()) - - # Verify no unauthorized change was persisted - site_a.refresh_from_db() - self.assertIsNone(site_a.service, "Unauthorized change should not be persisted") - - def test_render_gate_with_field_policy_for_base_operations(self): - """Test render gate with field policy for base operations.""" - site_a = Site.objects.create(name="Site A") - - permission_calls = [] - - def deny_policy(request, obj, config, selection): - """Track calls and deny access.""" - permission_calls.append(selection) - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_render_uses_field_policy = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=deny_policy, - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - - # This should call the permission policy during form creation (render gate) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # Should have been called during render gate (with selection=None) - self.assertGreater(len(permission_calls), 0, "Permission should be called during render") - self.assertIsNone( - permission_calls[0], "First call should be render gate with selection=None" - ) - - # Field should be disabled due to render gate - if "site_binding" in form.fields: - self.assertTrue( - form.fields["site_binding"].disabled, - "Field should be disabled due to render gate policy", - ) - - # Form should be valid because disabled fields are ignored - is_valid = form.is_valid() - self.assertTrue(is_valid, "Form should be valid because field is disabled") - - def test_base_operation_permission_system_comprehensive_validation(self): - """Comprehensive test to verify all aspects of base operation permission system.""" - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B") - - validation_calls = [] - - def selective_policy(request, obj, config, selection): - """Allow only Site B, deny Site A.""" - validation_calls.append(selection) - if selection and getattr(selection, "name", None) == "Site B": - return True - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=selective_policy, - permission_denied_message="Custom permission denied message", - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test denied selection (Site A) - form_denied = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - self.assertFalse(form_denied.is_valid()) - self.assertIn( - "custom permission denied message", - form_denied.errors.get("site_binding", [""])[0].lower(), - ) - - # Verify no binding was created - site_a.refresh_from_db() - self.assertIsNone(site_a.service) - - # Test allowed selection (Site B) - form_allowed = form_cls( - {"name": self.service.name, "site_binding": site_b.pk}, - instance=self.service, - ) - self.assertTrue(form_allowed.is_valid()) - saved_service = form_allowed.save() - - # Verify the allowed binding was created - site_b.refresh_from_db() - self.assertEqual(site_b.service, saved_service) - - # Verify permission was called for both validations - self.assertGreater( - len(validation_calls), 1, "Permission should be called for both validations" - ) - - -class BasePermissionEdgeCaseTests(BaseAdminMixinTestCase): - """Test suite for complex permission edge cases and advanced scenarios.""" - - def test_complex_user_hierarchy_permissions(self): - """Test permission evaluation with complex user hierarchies and roles.""" - site_a = Site.objects.create(name="Site A") - - # Create users with different hierarchy levels - superuser = User.objects.create_user( - username="superuser", password="test", is_superuser=True, is_staff=True - ) - admin_user = User.objects.create_user(username="admin", password="test", is_staff=True) - staff_user = User.objects.create_user(username="staff", password="test", is_staff=True) - regular_user = User.objects.create_user(username="regular", password="test", is_staff=False) - - # Add custom attributes to simulate role hierarchy - admin_user.role = "admin" - staff_user.role = "staff" - regular_user.role = "user" - - def hierarchical_permission(request, obj, config, selection): - """Permission based on user hierarchy.""" - user = request.user - if user.is_superuser: - return True - if hasattr(user, "role"): - if user.role == "admin": - return True - elif user.role == "staff": - # Staff can only bind sites with "Staff" in the name - return selection and "Staff" in getattr(selection, "name", "") - else: - return False - # Default fallback for users without role attribute - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_render_uses_field_policy = True # Use field policy for render gate - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=hierarchical_permission, - ) - } - - admin_inst = TestAdmin(Service, DummySite()) - - # Test superuser access (should always work) - request_super = self.factory.post("/") - request_super.user = superuser - form_cls = admin_inst.get_form(request_super, self.service) - form_super = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - self.assertTrue(form_super.is_valid(), "Superuser should have access") - - # Test admin user access (should work) - request_admin = self.factory.post("/") - request_admin.user = admin_user - form_cls = admin_inst.get_form(request_admin, self.service) - form_admin = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - self.assertTrue(form_admin.is_valid(), "Admin user should have access") - - # Test staff user with non-staff site (should fail) - request_staff = self.factory.post("/") - request_staff.user = staff_user - form_cls = admin_inst.get_form(request_staff, self.service) - form_staff = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - # The field should be disabled by render gate since staff user with no selection - # returns False from hierarchical_permission (selection=None case) - self.assertTrue( - "site_binding" not in form_staff.fields or form_staff.fields["site_binding"].disabled, - "Field should be disabled for staff user by render gate", - ) - - # Form should be valid because disabled fields are ignored - self.assertTrue(form_staff.is_valid(), "Form should be valid with disabled field") - - # Verify no binding is created when form is saved - form_staff.save() - site_a.refresh_from_db() - self.assertIsNone(site_a.service, "No binding should be created with disabled field") - - # Test staff user with staff site (should work) - staff_site = Site.objects.create(name="Staff Site") - form_staff_allowed = form_cls( - {"name": self.service.name, "site_binding": staff_site.pk}, - instance=self.service, - ) - self.assertTrue(form_staff_allowed.is_valid(), "Staff user should access staff site") - - # Test regular user (field should be disabled) - request_regular = self.factory.post("/") - request_regular.user = regular_user - form_cls = admin_inst.get_form(request_regular, self.service) - form_regular = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - # Field should be disabled for regular user - self.assertTrue( - "site_binding" not in form_regular.fields - or form_regular.fields["site_binding"].disabled, - "Field should be disabled for regular user", - ) - self.assertTrue(form_regular.is_valid(), "Form should be valid with disabled field") - - def test_permission_caching_and_performance(self): - """Test permission caching and performance with repeated evaluations.""" - sites = [Site.objects.create(name=f"Site {i}") for i in range(10)] - - call_count = {"count": 0} - - def counting_permission(request, obj, config, selection): - """Permission that counts how many times it's called.""" - call_count["count"] += 1 - # Allow only even-numbered sites - if selection: - site_num = getattr(selection, "name", "").split()[-1] - try: - return int(site_num) % 2 == 0 - except (ValueError, IndexError): - return False - return True - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=counting_permission, - ) - } - - admin_inst = TestAdmin(Service, DummySite()) - request = self.factory.post("/") - - # Test multiple form validations with same data - initial_count = call_count["count"] - - for i in range(3): # Validate same form multiple times - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": sites[2].pk}, # Even site - instance=self.service, - ) - is_valid = form.is_valid() - self.assertTrue(is_valid, f"Form should be valid on iteration {i}") - - # Verify permission was called multiple times (no caching by default) - calls_made = call_count["count"] - initial_count - self.assertGreater( - calls_made, 2, "Permission should be called multiple times without caching" - ) - - # Test with different selections to verify performance characteristics - performance_start = call_count["count"] - - for site in sites[:5]: # Test first 5 sites - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site.pk}, - instance=self.service, - ) - form.is_valid() - - performance_calls = call_count["count"] - performance_start - self.assertEqual(performance_calls, 5, "Should make exactly one call per unique validation") - - def test_permission_inheritance_complex_admin_configurations(self): - """Test permission inheritance in complex admin configurations.""" - site_a = Site.objects.create(name="Site A") - ext_1 = Extension.objects.create(number="1001") - - # Global permission policy - def global_policy(request, obj, config, selection): - """Global policy that allows only staff users.""" - return getattr(request.user, "is_staff", False) - - # Field-specific permission that overrides global - def field_specific_policy(request, obj, config, selection): - """Field-specific policy that allows superusers only.""" - return getattr(request.user, "is_superuser", False) - - # Another field with no specific policy (inherits global) - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_permission_policy = staticmethod(global_policy) # Global policy - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=field_specific_policy, # Override global - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - # No permission specified - should inherit global - ), - } - - admin_inst = TestAdmin(Service, DummySite()) - - # Create test users - superuser = User.objects.create_user( - username="super", password="test", is_superuser=True, is_staff=True - ) - staff_user = User.objects.create_user(username="staff", password="test", is_staff=True) - regular_user = User.objects.create_user(username="regular", password="test", is_staff=False) - - # Test superuser (should access both fields) - request_super = self.factory.post("/") - request_super.user = superuser - form_cls = admin_inst.get_form(request_super, self.service) - form_super = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - "assigned_extensions": [ext_1.pk], - }, - instance=self.service, - ) - self.assertTrue(form_super.is_valid(), "Superuser should access both fields") - - # Test staff user (should access extensions but not sites) - request_staff = self.factory.post("/") - request_staff.user = staff_user - form_cls = admin_inst.get_form(request_staff, self.service) - - # Test site binding (should fail - requires superuser) - form_staff_site = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - self.assertFalse(form_staff_site.is_valid(), "Staff should not access site field") - - # Test extension binding only (should work - inherits global staff policy) - # The site_binding field will be present but we need to handle it properly - form_data = {"name": self.service.name, "assigned_extensions": [ext_1.pk]} - - # Create form to check field states - form_staff_ext = form_cls(form_data, instance=self.service) - - # If site_binding field is present and not disabled, we need to handle it - # Since staff user can't access site_binding (field-specific policy), provide no value - # The validation should only fail if we try to set a value - if ( - "site_binding" in form_staff_ext.fields - and not form_staff_ext.fields["site_binding"].disabled - ): - # Don't provide any value for site_binding - let it remain unset - pass - - # The issue is that the site_binding field should be disabled by render gate - # but it's not because the global policy allows staff users - # However, the field-specific policy denies staff users - # This is a complex scenario that shows the interaction between global and field policies - - # For this test, let's focus on testing that the extensions field works correctly - # We'll create a separate admin that only has the extensions field to avoid conflicts - - class ExtensionsOnlyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_permission_policy = staticmethod(global_policy) # Global policy - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - # No permission specified - should inherit global - ) - } - - ext_admin = ExtensionsOnlyAdmin(Service, DummySite()) - ext_form_cls = ext_admin.get_form(request_staff, self.service) - ext_form = ext_form_cls( - {"name": self.service.name, "assigned_extensions": [ext_1.pk]}, - instance=self.service, - ) - - # This should work because staff user passes global policy - self.assertTrue( - ext_form.is_valid(), "Staff should access extension field via global policy" - ) - saved_service = ext_form.save() - ext_1.refresh_from_db() - self.assertEqual(ext_1.service, saved_service, "Extension should be bound to service") - - # Test regular user (should have fields disabled by render gate) - request_regular = self.factory.post("/") - request_regular.user = regular_user - form_cls = admin_inst.get_form(request_regular, self.service) - form_regular = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - "assigned_extensions": [ext_1.pk], - }, - instance=self.service, - ) - - # Regular user should have both fields disabled by render gate (global policy denies non-staff) - site_disabled = ( - "site_binding" not in form_regular.fields - or form_regular.fields["site_binding"].disabled - ) - ext_disabled = ( - "assigned_extensions" not in form_regular.fields - or form_regular.fields["assigned_extensions"].disabled - ) - - self.assertTrue(site_disabled, "Site field should be disabled for regular user") - self.assertTrue(ext_disabled, "Extensions field should be disabled for regular user") - self.assertTrue(form_regular.is_valid(), "Form should be valid with disabled fields") - - def test_permission_denied_message_customization(self): - """Test custom permission denied messages and error handling.""" - site_a = Site.objects.create(name="Site A") - - # Test different message customization approaches - class CustomMessagePolicy: - """Policy with custom message attribute.""" - - permission_denied_message = "Access denied: Insufficient privileges for this operation" - - def __call__(self, request, obj, config, selection): - return False - - def custom_message_function(request, obj, config, selection): - """Function with custom message attribute.""" - return False - - # Add custom message as function attribute - custom_message_function.permission_denied_message = "Function-based custom error message" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=CustomMessagePolicy(), - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - permission=custom_message_function, - ), - } - - admin_inst = TestAdmin(Service, DummySite()) - request = self.factory.post("/") - form_cls = admin_inst.get_form(request, self.service) - - # Test custom message from policy class - form_site = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - self.assertFalse(form_site.is_valid()) - site_error = form_site.errors.get("site_binding", [""])[0].lower() - self.assertIn("insufficient privileges", site_error) - - # Test custom message from function attribute - ext_1 = Extension.objects.create(number="1001") - form_ext = form_cls( - {"name": self.service.name, "assigned_extensions": [ext_1.pk]}, - instance=self.service, - ) - self.assertFalse(form_ext.is_valid()) - ext_error = form_ext.errors.get("assigned_extensions", [""])[0].lower() - self.assertIn("function-based custom error", ext_error) - - def test_permission_denied_message_localization_support(self): - """Test permission denied message localization and internationalization support.""" - from django.utils.translation import gettext_lazy as _ - - site_a = Site.objects.create(name="Site A") - - class LocalizedMessagePolicy: - """Policy with localized message.""" - - permission_denied_message = _( - "Permission denied: You do not have access to this resource" - ) - - def __call__(self, request, obj, config, selection): - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=LocalizedMessagePolicy(), - ) - } - - admin_inst = TestAdmin(Service, DummySite()) - request = self.factory.post("/") - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertFalse(form.is_valid()) - error_message = form.errors.get("site_binding", [""])[0] - - # Verify the message contains expected localized content - self.assertIn("permission denied", error_message.lower()) - self.assertIn("do not have access", error_message.lower()) - - def test_complex_permission_scenarios_with_object_state(self): - """Test complex permission scenarios based on object state and relationships.""" - # Create complex test scenario - service1 = Service.objects.create(name="active-service") - service2 = Service.objects.create(name="inactive-service") - - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B", service=service2) # Already bound - - def state_dependent_permission(request, obj, config, selection): - """Permission that depends on object and selection state.""" - # Allow binding only if: - # 1. Service name contains "active" - # 2. Selection is not already bound to another service - # 3. Selection name doesn't contain "restricted" - - if not obj or not hasattr(obj, "name"): - return False - - if "inactive" in obj.name: - return False - - if selection: - # Check if selection is already bound - if hasattr(selection, "service") and selection.service and selection.service != obj: - return False - - # Check for restricted names - if "restricted" in getattr(selection, "name", "").lower(): - return False - - return True - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=state_dependent_permission, - permission_denied_message="Cannot bind: check service status and site availability", - ) - } - - admin_inst = TestAdmin(Service, DummySite()) - request = self.factory.post("/") - - # Test with active service and unbound site (should work) - form_cls = admin_inst.get_form(request, service1) - form_allowed = form_cls( - {"name": service1.name, "site_binding": site_a.pk}, - instance=service1, - ) - self.assertTrue( - form_allowed.is_valid(), "Should allow binding unbound site to active service" - ) - - # Test with inactive service (should fail) - form_cls = admin_inst.get_form(request, service2) - form_inactive = form_cls( - {"name": service2.name, "site_binding": site_a.pk}, - instance=service2, - ) - self.assertFalse(form_inactive.is_valid(), "Should deny binding to inactive service") - - # Test with already bound site (should fail) - form_bound = form_cls( - {"name": service1.name, "site_binding": site_b.pk}, - instance=service1, - ) - self.assertFalse(form_bound.is_valid(), "Should deny binding already bound site") - - # Test with restricted site name - restricted_site = Site.objects.create(name="Restricted Site") - form_cls = admin_inst.get_form(request, service1) - form_restricted = form_cls( - {"name": service1.name, "site_binding": restricted_site.pk}, - instance=service1, - ) - self.assertFalse(form_restricted.is_valid(), "Should deny binding restricted site") - - def test_permission_evaluation_with_concurrent_modifications(self): - """Test permission evaluation behavior with concurrent data modifications.""" - site_a = Site.objects.create(name="Site A") - - evaluation_context = {"evaluations": []} - - def context_tracking_permission(request, obj, config, selection): - """Permission that tracks evaluation context.""" - context = { - "obj_name": getattr(obj, "name", None) if obj else None, - "selection_name": getattr(selection, "name", None) if selection else None, - "selection_service": getattr(selection, "service", None) if selection else None, - } - evaluation_context["evaluations"].append(context) - - # Allow if selection is not bound to any service - if selection and hasattr(selection, "service"): - return selection.service is None - return True - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=context_tracking_permission, - ) - } - - admin_inst = TestAdmin(Service, DummySite()) - request = self.factory.post("/") - - # Initial evaluation (site is unbound) - form_cls = admin_inst.get_form(request, self.service) - form1 = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - result1 = form1.is_valid() - self.assertTrue(result1, "Should allow binding unbound site") - - # Simulate concurrent modification - bind site to another service - other_service = Service.objects.create(name="other-service") - site_a.service = other_service - site_a.save() - - # Second evaluation (site is now bound) - form2 = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - result2 = form2.is_valid() - self.assertFalse(result2, "Should deny binding already bound site") - - # Verify evaluation context was tracked - self.assertGreaterEqual(len(evaluation_context["evaluations"]), 2) - - # Verify context shows the state change - first_eval = evaluation_context["evaluations"][0] - last_eval = evaluation_context["evaluations"][-1] - - self.assertIsNone( - first_eval["selection_service"], "First evaluation should show unbound site" - ) - self.assertIsNotNone( - last_eval["selection_service"], "Last evaluation should show bound site" - ) - - def test_permission_policy_error_handling_and_recovery(self): - """Test permission policy error handling and graceful recovery.""" - site_a = Site.objects.create(name="Site A") - - call_count = {"count": 0} - - def error_prone_permission(request, obj, config, selection): - """Permission that fails on first call but succeeds on retry.""" - call_count["count"] += 1 - - if call_count["count"] == 1: - # Simulate an error condition - raise ValueError("Simulated permission evaluation error") - - # Succeed on subsequent calls - return True - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=error_prone_permission, - ) - } - - admin_inst = TestAdmin(Service, DummySite()) - request = self.factory.post("/") - form_cls = admin_inst.get_form(request, self.service) - - # First attempt should raise the error (not handled gracefully) - form1 = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # The form validation should raise the permission error - with self.assertRaises(ValueError) as cm: - form1.is_valid() - - self.assertIn("Simulated permission evaluation error", str(cm.exception)) - - # Second attempt should succeed (error_prone_permission succeeds on retry) - form2 = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - result2 = form2.is_valid() - self.assertTrue(result2, "Should succeed on retry after error recovery") - - # Verify both calls were made - self.assertEqual(call_count["count"], 2, "Should have made two permission calls") diff --git a/tests/test_base_validation.py b/tests/test_base_validation.py deleted file mode 100644 index 066a942..0000000 --- a/tests/test_base_validation.py +++ /dev/null @@ -1,1202 +0,0 @@ -"""Test suite for base operation form validation and data integrity. - -This module tests form validation, data integrity, and error handling for base -(non-bulk) operations, focusing on validation scenarios, constraint handling, -and error recovery. Base operations use the default bulk=False setting and -process items individually. -""" - -# Django imports -from django import forms -from django.contrib import admin - -# Project imports -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -# Test imports -from .models import Extension, Service, Site, UniqueExtension -from .shared_test_base import BaseAdminMixinTestCase, DummySite - - -class BaseFormValidationTests(BaseAdminMixinTestCase): - """Test suite for base operation form validation scenarios.""" - - def test_base_operation_required_field_validation(self): - """Test required field validation for base operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - required=True, # Required field - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test with empty selection (should fail validation) - form = form_cls( - {"name": self.service.name, "site_binding": ""}, - instance=self.service, - ) - - self.assertFalse(form.is_valid()) - self.assertIn("site_binding", form.errors) - self.assertIn("required", form.errors["site_binding"][0].lower()) - - def test_base_operation_optional_field_validation(self): - """Test optional field validation for base operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - required=False, # Optional field - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test with empty selection (should pass validation) - form = form_cls( - {"name": self.service.name, "site_binding": ""}, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - - def test_base_operation_custom_validation_hook_success(self): - """Test custom validation hook that passes for base operations.""" - - def custom_validation(instance, selection, request): - """Custom validation that always passes.""" - # No exception means validation passes - pass - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=custom_validation, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - saved_service = form.save() - - # Verify the binding was created - site_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - - def test_base_operation_custom_validation_hook_failure(self): - """Test custom validation hook that fails for base operations.""" - - def custom_validation(instance, selection, request): - """Custom validation that always fails.""" - raise forms.ValidationError("Custom validation failed") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=custom_validation, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertFalse(form.is_valid()) - self.assertIn("site_binding", form.errors) - self.assertIn("custom validation failed", form.errors["site_binding"][0].lower()) - - # Verify no binding was created - site_a.refresh_from_db() - self.assertIsNone(site_a.service) - - def test_base_operation_validation_with_selection_context(self): - """Test validation hook that uses selection context for base operations.""" - - def selection_based_validation(instance, selection, request): - """Validation that depends on the selection.""" - if selection and hasattr(selection, "name"): - if selection.name == "Invalid Site": - raise forms.ValidationError("This site is not allowed") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=selection_based_validation, - ) - } - - valid_site = Site.objects.create(name="Valid Site") - invalid_site = Site.objects.create(name="Invalid Site") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test valid selection - form_valid = form_cls( - {"name": self.service.name, "site_binding": valid_site.pk}, - instance=self.service, - ) - self.assertTrue(form_valid.is_valid()) - - # Test invalid selection - form_invalid = form_cls( - {"name": self.service.name, "site_binding": invalid_site.pk}, - instance=self.service, - ) - self.assertFalse(form_invalid.is_valid()) - self.assertIn("this site is not allowed", form_invalid.errors["site_binding"][0].lower()) - - def test_base_operation_multiple_field_validation(self): - """Test validation across multiple reverse relation fields for base operations.""" - - def site_validation(instance, selection, request): - """Validation for site field.""" - if selection and hasattr(selection, "name"): - if "invalid" in selection.name.lower(): - raise forms.ValidationError("Invalid site selected") - - def extension_validation(instance, selection, request): - """Validation for extension field.""" - if selection: - for ext in selection if hasattr(selection, "__iter__") else [selection]: - if hasattr(ext, "number") and ext.number.startswith("999"): - raise forms.ValidationError("Extension numbers cannot start with 999") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=site_validation, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - clean=extension_validation, - ), - } - - valid_site = Site.objects.create(name="Valid Site") - invalid_site = Site.objects.create(name="Invalid Site") - valid_ext = Extension.objects.create(number="1001") - invalid_ext = Extension.objects.create(number="9991") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test both fields valid - form_valid = form_cls( - { - "name": self.service.name, - "site_binding": valid_site.pk, - "assigned_extensions": [valid_ext.pk], - }, - instance=self.service, - ) - self.assertTrue(form_valid.is_valid()) - - # Test site invalid, extension valid - form_site_invalid = form_cls( - { - "name": self.service.name, - "site_binding": invalid_site.pk, - "assigned_extensions": [valid_ext.pk], - }, - instance=self.service, - ) - self.assertFalse(form_site_invalid.is_valid()) - self.assertIn("site_binding", form_site_invalid.errors) - self.assertNotIn("assigned_extensions", form_site_invalid.errors) - - # Test site valid, extension invalid - form_ext_invalid = form_cls( - { - "name": self.service.name, - "site_binding": valid_site.pk, - "assigned_extensions": [invalid_ext.pk], - }, - instance=self.service, - ) - self.assertFalse(form_ext_invalid.is_valid()) - self.assertNotIn("site_binding", form_ext_invalid.errors) - self.assertIn("assigned_extensions", form_ext_invalid.errors) - - -class BaseDataIntegrityTests(BaseAdminMixinTestCase): - """Test suite for base operation data consistency and integrity.""" - - def test_base_operation_invalid_primary_key_handling(self): - """Test handling of invalid primary keys for base operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test with non-existent primary key - form = form_cls( - {"name": self.service.name, "site_binding": 99999}, - instance=self.service, - ) - - # Form should be invalid due to invalid choice - self.assertFalse(form.is_valid()) - self.assertIn("site_binding", form.errors) - - def test_base_operation_constraint_violation_handling(self): - """Test handling of database constraint violations for base operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "unique_binding": ReverseRelationConfig( - model=UniqueExtension, - fk_field="service", - multiple=False, - ) - } - - # Create a unique extension already bound to another service - other_service = Service.objects.create(name="other-service") - unique_ext = UniqueExtension.objects.create(number="1001", service=other_service) - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Try to bind the already-bound unique extension to our service - form = form_cls( - {"name": self.service.name, "unique_binding": unique_ext.pk}, - instance=self.service, - ) - - # Form validation should pass (constraint is checked at save time) - self.assertTrue(form.is_valid()) - - # Save should succeed because the mixin uses unbind-before-bind strategy - # The unique extension will be unbound from other_service and bound to our service - saved_service = form.save() - - # Verify the binding was transferred correctly - unique_ext.refresh_from_db() - self.assertEqual(unique_ext.service, saved_service) - - # Verify it was unbound from the other service - other_service.refresh_from_db() - with self.assertRaises(UniqueExtension.DoesNotExist): - _ = other_service.unique_extension - - def test_base_operation_model_state_consistency(self): - """Test model state consistency during base operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B") - - # Initially bind site_a to the service - site_a.service = self.service - site_a.save() - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Change binding to site_b - form = form_cls( - {"name": self.service.name, "site_binding": site_b.pk}, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - form.save() - - # Verify state consistency: site_a unbound, site_b bound - site_a.refresh_from_db() - site_b.refresh_from_db() - self.assertIsNone(site_a.service) - self.assertEqual(site_b.service, self.service) - - def test_base_operation_concurrent_modification_handling(self): - """Test handling of concurrent modifications for base operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Create form with site_a selection - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # Simulate concurrent modification: another process binds site_a - other_service = Service.objects.create(name="other-service") - site_a.service = other_service - site_a.save() - - # Our form should still be valid - self.assertTrue(form.is_valid()) - - # Save should succeed (unbind from other_service, bind to our service) - saved_service = form.save() - - # Verify final state - site_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - - def test_base_operation_empty_queryset_handling(self): - """Test handling of empty querysets for base operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - limit_choices_to=lambda qs, instance, request: qs.none(), # Empty queryset - ) - } - - Site.objects.create(name="Site A") # Create a site but filter it out - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls(instance=self.service) - - # Field should exist but have no choices - self.assertIn("site_binding", form.fields) - field = form.fields["site_binding"] - - # Queryset should be empty - if hasattr(field, "queryset"): - self.assertEqual(field.queryset.count(), 0) - - -class BaseErrorHandlingTests(BaseAdminMixinTestCase): - """Test suite for base operation error scenarios and recovery.""" - - def test_base_operation_form_error_message_accuracy(self): - """Test accuracy of form error messages for base operations.""" - - def validation_with_custom_message(instance, selection, request): - """Validation that raises a specific error message.""" - raise forms.ValidationError("This is a custom error message for testing") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=validation_with_custom_message, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertFalse(form.is_valid()) - self.assertIn("site_binding", form.errors) - self.assertEqual( - form.errors["site_binding"][0], "This is a custom error message for testing" - ) - - def test_base_operation_validation_error_with_multiple_messages(self): - """Test validation errors with multiple messages for base operations.""" - - def multi_message_validation(instance, selection, request): - """Validation that raises multiple error messages.""" - raise forms.ValidationError(["First error message", "Second error message"]) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=multi_message_validation, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - self.assertFalse(form.is_valid()) - self.assertIn("site_binding", form.errors) - errors = form.errors["site_binding"] - self.assertIn("First error message", str(errors)) - self.assertIn("Second error message", str(errors)) - - def test_base_operation_exception_during_validation(self): - """Test handling of unexpected exceptions during validation for base operations.""" - - def failing_validation(instance, selection, request): - """Validation that raises an unexpected exception.""" - raise RuntimeError("Unexpected error during validation") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=failing_validation, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - - # Unexpected exceptions should propagate (not be caught as ValidationError) - with self.assertRaises(RuntimeError): - form.is_valid() - - def test_base_operation_validation_with_unsaved_instance(self): - """Test validation behavior with unsaved model instances for base operations.""" - - def instance_dependent_validation(instance, selection, request): - """Validation that depends on the instance state.""" - if instance and not instance.pk: - raise forms.ValidationError("Cannot validate with unsaved instance") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=instance_dependent_validation, - ) - } - - site_a = Site.objects.create(name="Site A") - unsaved_service = Service(name="unsaved-service") # Not saved to DB - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, unsaved_service) - form = form_cls( - {"name": unsaved_service.name, "site_binding": site_a.pk}, - instance=unsaved_service, - ) - - self.assertFalse(form.is_valid()) - self.assertIn("site_binding", form.errors) - self.assertIn( - "cannot validate with unsaved instance", form.errors["site_binding"][0].lower() - ) - - def test_base_operation_error_recovery_after_validation_failure(self): - """Test error recovery after validation failure for base operations.""" - - validation_calls = [] - - def conditional_validation(instance, selection, request): - """Validation that fails first time, succeeds second time.""" - validation_calls.append(selection) - if len(validation_calls) == 1: - raise forms.ValidationError("First attempt fails") - # Second attempt succeeds (no exception) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=conditional_validation, - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # First attempt should fail - form1 = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - self.assertFalse(form1.is_valid()) - - # Second attempt should succeed - form2 = form_cls( - {"name": self.service.name, "site_binding": site_a.pk}, - instance=self.service, - ) - self.assertTrue(form2.is_valid()) - saved_service = form2.save() - - # Verify the binding was created on successful attempt - site_a.refresh_from_db() - self.assertEqual(site_a.service, saved_service) - - def test_base_operation_validation_with_different_model_states(self): - """Test validation with different model lifecycle states for base operations.""" - - def state_aware_validation(instance, selection, request): - """Validation that behaves differently based on model state.""" - if instance and instance.pk: - # Existing instance - stricter validation - if selection and hasattr(selection, "name"): - if "strict" not in selection.name.lower(): - raise forms.ValidationError("Existing instances require strict sites") - else: - # New instance - more lenient validation - pass # Allow any selection - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=state_aware_validation, - ) - } - - lenient_site = Site.objects.create(name="Lenient Site") - strict_site = Site.objects.create(name="Strict Site") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - - # Test with new instance (should be lenient) - new_service = Service(name="new-service") - form_cls_new = admin_inst.get_form(request, new_service) - form_new = form_cls_new( - {"name": new_service.name, "site_binding": lenient_site.pk}, - instance=new_service, - ) - self.assertTrue(form_new.is_valid()) - - # Test with existing instance and lenient site (should fail) - form_cls_existing = admin_inst.get_form(request, self.service) - form_existing_lenient = form_cls_existing( - {"name": self.service.name, "site_binding": lenient_site.pk}, - instance=self.service, - ) - self.assertFalse(form_existing_lenient.is_valid()) - - # Test with existing instance and strict site (should pass) - form_existing_strict = form_cls_existing( - {"name": self.service.name, "site_binding": strict_site.pk}, - instance=self.service, - ) - self.assertTrue(form_existing_strict.is_valid()) - - -class BaseValidationEdgeCaseTests(BaseAdminMixinTestCase): - """Test suite for complex validation edge cases and boundary conditions.""" - - def test_validation_with_circular_relationship_dependencies(self): - """Test validation with circular relationship dependencies.""" - - def circular_validation(instance, selection, request): - """Validation that creates circular dependency.""" - if selection and hasattr(selection, 'service'): - # Check if the selected site's service would create a circular reference - if selection.service and selection.service != instance: - raise forms.ValidationError( - "Cannot select a site that belongs to a different service" - ) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=circular_validation, - ) - } - - # Create a complex scenario with multiple services and sites - service_a = Service.objects.create(name="service-a") - service_b = Service.objects.create(name="service-b") - - site_a = Site.objects.create(name="Site A", service=service_a) - site_b = Site.objects.create(name="Site B", service=service_b) - site_unbound = Site.objects.create(name="Unbound Site") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service_a) - - # Test selecting unbound site (should pass) - form_unbound = form_cls( - {"name": service_a.name, "site_binding": site_unbound.pk}, - instance=service_a, - ) - self.assertTrue(form_unbound.is_valid()) - - # Test selecting site bound to different service (should fail) - form_circular = form_cls( - {"name": service_a.name, "site_binding": site_b.pk}, - instance=service_a, - ) - self.assertFalse(form_circular.is_valid()) - self.assertIn("site_binding", form_circular.errors) - self.assertIn("different service", form_circular.errors["site_binding"][0].lower()) - - # Test selecting site already bound to same service (should pass) - form_same = form_cls( - {"name": service_a.name, "site_binding": site_a.pk}, - instance=service_a, - ) - self.assertTrue(form_same.is_valid()) - - def test_validation_performance_with_large_datasets(self): - """Test validation performance and behavior with large datasets.""" - - validation_call_count = [] - - def performance_validation(instance, selection, request): - """Validation that tracks call count for performance testing.""" - validation_call_count.append(1) - # Simulate some processing time but keep it minimal for tests - if selection: - if hasattr(selection, '__iter__') and not isinstance(selection, str): - # Multiple selection - validate each item - for item in selection: - if hasattr(item, 'number') and item.number.startswith('invalid'): - raise forms.ValidationError(f"Invalid item: {item.number}") - else: - # Single selection - if hasattr(selection, 'number') and selection.number.startswith('invalid'): - raise forms.ValidationError(f"Invalid item: {selection.number}") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - clean=performance_validation, - ) - } - - # Create large dataset - large_extensions = self.create_large_dataset(50, "extensions") - invalid_ext = Extension.objects.create(number="invalid-ext") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test with large valid selection - valid_pks = [ext.pk for ext in large_extensions[:20]] # Select 20 items - form_large = form_cls( - {"name": self.service.name, "assigned_extensions": valid_pks}, - instance=self.service, - ) - - validation_call_count.clear() - is_valid = form_large.is_valid() - self.assertTrue(is_valid) - self.assertEqual(len(validation_call_count), 1) # Should be called once per field - - # Test with large selection including invalid item - invalid_pks = valid_pks + [invalid_ext.pk] - form_invalid = form_cls( - {"name": self.service.name, "assigned_extensions": invalid_pks}, - instance=self.service, - ) - - validation_call_count.clear() - is_valid = form_invalid.is_valid() - self.assertFalse(is_valid) - self.assertEqual(len(validation_call_count), 1) - self.assertIn("assigned_extensions", form_invalid.errors) - - def test_validation_with_concurrent_modifications(self): - """Test validation behavior with concurrent modifications during form processing.""" - - def concurrent_aware_validation(instance, selection, request): - """Validation that checks for concurrent modifications.""" - if selection and hasattr(selection, 'service'): - # Simulate checking if the selection was modified by another process - selection.refresh_from_db() - if selection.service and selection.service != instance: - raise forms.ValidationError( - "This item was modified by another process and is no longer available" - ) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=concurrent_aware_validation, - ) - } - - site = Site.objects.create(name="Concurrent Site") - other_service = Service.objects.create(name="other-service") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Create form with site selection - form = form_cls( - {"name": self.service.name, "site_binding": site.pk}, - instance=self.service, - ) - - # Simulate concurrent modification: another process binds the site - site.service = other_service - site.save() - - # Validation should detect the concurrent modification - self.assertFalse(form.is_valid()) - self.assertIn("site_binding", form.errors) - self.assertIn("modified by another process", form.errors["site_binding"][0].lower()) - - def test_custom_validator_integration_and_error_messages(self): - """Test custom validator integration with detailed error message handling.""" - - class CustomValidator: - """Custom validator class with complex validation logic.""" - - def __init__(self, max_items=3, forbidden_patterns=None): - self.max_items = max_items - self.forbidden_patterns = forbidden_patterns or [] - - def __call__(self, instance, selection, request): - """Validate selection with custom rules.""" - errors = [] - - if selection: - if hasattr(selection, '__iter__') and not isinstance(selection, str): - # Multiple selection validation - if len(selection) > self.max_items: - errors.append(f"Cannot select more than {self.max_items} items") - - for item in selection: - if hasattr(item, 'number'): - for pattern in self.forbidden_patterns: - if pattern in item.number: - errors.append(f"Item {item.number} contains forbidden pattern: {pattern}") - else: - # Single selection validation - if hasattr(selection, 'number'): - for pattern in self.forbidden_patterns: - if pattern in selection.number: - errors.append(f"Item {selection.number} contains forbidden pattern: {pattern}") - - if errors: - raise forms.ValidationError(errors) - - custom_validator = CustomValidator(max_items=2, forbidden_patterns=['999', 'bad']) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - clean=custom_validator, - ) - } - - # Create test data - good_ext1 = Extension.objects.create(number="1001") - good_ext2 = Extension.objects.create(number="1002") - good_ext3 = Extension.objects.create(number="1003") - bad_ext1 = Extension.objects.create(number="999-bad") - bad_ext2 = Extension.objects.create(number="bad-ext") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test valid selection within limits - form_valid = form_cls( - {"name": self.service.name, "assigned_extensions": [good_ext1.pk, good_ext2.pk]}, - instance=self.service, - ) - self.assertTrue(form_valid.is_valid()) - - # Test too many items - form_too_many = form_cls( - {"name": self.service.name, "assigned_extensions": [good_ext1.pk, good_ext2.pk, good_ext3.pk]}, - instance=self.service, - ) - self.assertFalse(form_too_many.is_valid()) - self.assertIn("assigned_extensions", form_too_many.errors) - self.assertIn("cannot select more than 2", form_too_many.errors["assigned_extensions"][0].lower()) - - # Test forbidden patterns - form_forbidden = form_cls( - {"name": self.service.name, "assigned_extensions": [good_ext1.pk, bad_ext1.pk]}, - instance=self.service, - ) - self.assertFalse(form_forbidden.is_valid()) - self.assertIn("assigned_extensions", form_forbidden.errors) - errors_str = str(form_forbidden.errors["assigned_extensions"]) - self.assertIn("forbidden pattern", errors_str.lower()) - self.assertIn("999", errors_str) - - def test_validation_with_complex_model_inheritance_hierarchies(self): - """Test validation with complex model inheritance scenarios.""" - - def inheritance_aware_validation(instance, selection, request): - """Validation that handles model inheritance.""" - if selection: - # Check if selection is compatible with instance type - if hasattr(instance, '_meta') and hasattr(selection, '_meta'): - instance_app = instance._meta.app_label - selection_app = selection._meta.app_label - - # Simulate cross-app validation rules - if instance_app != selection_app and selection_app: - raise forms.ValidationError( - f"Cannot bind {selection_app} models to {instance_app} models" - ) - - # Check for specific inheritance patterns - if hasattr(selection, '__class__'): - class_name = selection.__class__.__name__ - if class_name.startswith('Unique') and not hasattr(instance, 'unique_extension'): - # This is a unique extension but instance doesn't support it - pass # Allow for testing purposes - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "unique_binding": ReverseRelationConfig( - model=UniqueExtension, - fk_field="service", - multiple=False, - clean=inheritance_aware_validation, - ), - "regular_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - clean=inheritance_aware_validation, - ) - } - - # Create test data with different model types - unique_ext = UniqueExtension.objects.create(number="unique-1") - regular_ext = Extension.objects.create(number="regular-1") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test valid inheritance scenario - form_valid = form_cls( - { - "name": self.service.name, - "unique_binding": unique_ext.pk, - "regular_extensions": [regular_ext.pk], - }, - instance=self.service, - ) - self.assertTrue(form_valid.is_valid()) - - def test_validation_with_database_transaction_rollback(self): - """Test validation behavior during database transaction rollbacks.""" - - def transaction_aware_validation(instance, selection, request): - """Validation that interacts with database transactions.""" - if selection and hasattr(selection, 'number'): - # Simulate a validation that requires database access - try: - # Check if there are any conflicting extensions - conflicting = Extension.objects.filter( - number=selection.number, - service__isnull=False - ).exclude(service=instance) - - if conflicting.exists(): - raise forms.ValidationError( - f"Extension {selection.number} is already assigned to another service" - ) - except Exception as e: - # Handle database errors during validation - raise forms.ValidationError(f"Database error during validation: {str(e)}") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - clean=transaction_aware_validation, - ) - } - - # Create conflicting scenario - other_service = Service.objects.create(name="other-service") - conflicting_ext = Extension.objects.create(number="conflict-ext", service=other_service) - available_ext = Extension.objects.create(number="available-ext") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test with available extension (should pass) - form_available = form_cls( - {"name": self.service.name, "assigned_extensions": [available_ext.pk]}, - instance=self.service, - ) - self.assertTrue(form_available.is_valid()) - - # Test with conflicting extension (should fail due to validation logic) - # Note: The mixin uses unbind-before-bind, so we need to test differently - form_conflict = form_cls( - {"name": self.service.name, "assigned_extensions": [conflicting_ext.pk]}, - instance=self.service, - ) - - # The form should be valid because the mixin handles the conflict by unbinding first - # But our custom validation should catch it - is_valid = form_conflict.is_valid() - if not is_valid: - self.assertIn("assigned_extensions", form_conflict.errors) - self.assertIn("already assigned", form_conflict.errors["assigned_extensions"][0].lower()) - else: - # If the form is valid, it means the mixin's unbind-before-bind logic worked - # This is actually correct behavior, so we'll accept it - pass - - def test_validation_with_nested_form_dependencies(self): - """Test validation with complex nested form dependencies.""" - - def dependency_validation(instance, selection, request): - """Validation that depends on other form fields.""" - # Access the form through the validation context - # This simulates cross-field validation dependencies - if hasattr(request, '_form_instance'): - form = request._form_instance - if hasattr(form, 'cleaned_data'): - # Check dependencies with other fields - site_selection = form.cleaned_data.get('site_binding') - if site_selection and selection: - # Simulate business rule: certain extensions only work with certain sites - if hasattr(site_selection, 'name') and 'restricted' in site_selection.name.lower(): - if hasattr(selection, '__iter__') and not isinstance(selection, str): - for ext in selection: - if hasattr(ext, 'number') and not ext.number.startswith('auth'): - raise forms.ValidationError( - "Restricted sites can only use authorized extensions" - ) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - clean=dependency_validation, - ) - } - - # Create test data - normal_site = Site.objects.create(name="Normal Site") - restricted_site = Site.objects.create(name="Restricted Site") - auth_ext = Extension.objects.create(number="auth-1001") - normal_ext = Extension.objects.create(number="normal-1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test normal site with any extension (should pass) - form_normal = form_cls( - { - "name": self.service.name, - "site_binding": normal_site.pk, - "assigned_extensions": [normal_ext.pk], - }, - instance=self.service, - ) - self.assertTrue(form_normal.is_valid()) - - # Test restricted site with authorized extension (should pass) - form_restricted_auth = form_cls( - { - "name": self.service.name, - "site_binding": restricted_site.pk, - "assigned_extensions": [auth_ext.pk], - }, - instance=self.service, - ) - # Note: This test may not work as expected due to form validation order - # but demonstrates the concept of cross-field validation - - def test_validation_error_aggregation_and_reporting(self): - """Test aggregation and reporting of multiple validation errors.""" - - def multi_error_validation(instance, selection, request): - """Validation that can produce multiple errors.""" - errors = [] - - if selection: - if hasattr(selection, '__iter__') and not isinstance(selection, str): - # Check multiple conditions that can each produce errors - if len(selection) == 0: - errors.append("At least one item must be selected") - - if len(selection) > 5: - errors.append("Cannot select more than 5 items") - - for item in selection: - if hasattr(item, 'number'): - if item.number.startswith('error'): - errors.append(f"Item {item.number} is not allowed") - if len(item.number) < 3: - errors.append(f"Item {item.number} has invalid format") - if item.number.isdigit() and int(item.number) < 1000: - errors.append(f"Item {item.number} is below minimum value") - - if errors: - raise forms.ValidationError(errors) - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - clean=multi_error_validation, - ) - } - - # Create test data with various error conditions - error_ext = Extension.objects.create(number="error-ext") - short_ext = Extension.objects.create(number="12") - low_ext = Extension.objects.create(number="500") - valid_ext = Extension.objects.create(number="1001") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, self.service) - - # Test multiple error conditions - form_multi_error = form_cls( - { - "name": self.service.name, - "assigned_extensions": [error_ext.pk, short_ext.pk, low_ext.pk], - }, - instance=self.service, - ) - - self.assertFalse(form_multi_error.is_valid()) - self.assertIn("assigned_extensions", form_multi_error.errors) - - # Check that multiple errors are reported - error_messages = str(form_multi_error.errors["assigned_extensions"]) - self.assertIn("not allowed", error_messages.lower()) - self.assertIn("invalid format", error_messages.lower()) - self.assertIn("below minimum", error_messages.lower()) - - # Test valid scenario - form_valid = form_cls( - {"name": self.service.name, "assigned_extensions": [valid_ext.pk]}, - instance=self.service, - ) - self.assertTrue(form_valid.is_valid()) diff --git a/tests/test_base_widgets.py b/tests/test_base_widgets.py deleted file mode 100644 index b968409..0000000 --- a/tests/test_base_widgets.py +++ /dev/null @@ -1,1322 +0,0 @@ -"""Base widget compatibility tests for the ReverseRelationAdminMixin. - -This module contains comprehensive tests for widget compatibility with reverse relation fields, -covering standard Django widgets, rendering scenarios, and JavaScript/media handling. -""" - -from django.contrib import admin -from django.forms import widgets - -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -from .models import Extension, Service, Site -from .shared_test_base import BaseAdminMixinTestCase - - -class BaseWidgetCompatibilityTests(BaseAdminMixinTestCase): - """Test widget compatibility and rendering for base (non-bulk) operations.""" - - def setUp(self): - """Set up test data for widget compatibility testing.""" - super().setUp() - self.extensions = self.create_test_extensions(5) - self.sites = self.create_test_sites(3) - self.request = self.factory.get("/") - - def test_select_widget_single_relation(self): - """Test Select widget compatibility with single-select reverse relations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - label="Site", - widget=widgets.Select, - ), - } - - admin_instance = TestAdmin(Service, self.site) - form_cls = admin_instance.get_form(self.request, self.service) - form = form_cls(instance=self.service) - - # Assert field exists and has correct widget - self.assertIn("site_binding", form.fields) - field = form.fields["site_binding"] - self.assertIsInstance(field.widget, widgets.Select) - - # Test widget rendering with empty state - widget_html = field.widget.render("site_binding", None) - self.assertIsInstance(widget_html, str) - self.assertIn("{html}" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - label="Site", - widget=CustomSelectWidget(custom_option="test-value"), - ), - } - - admin_instance = TestAdmin(Service, self.site) - form_cls = admin_instance.get_form(self.request, self.service) - form = form_cls(instance=self.service) - - # Test custom widget functionality - field = form.fields["site_binding"] - self.assertIsInstance(field.widget, CustomSelectWidget) - self.assertEqual(field.widget.custom_option, "test-value") - - # Test custom rendering - widget_html = field.widget.render("site_binding", None) - self.assertIn("", widget_html) - self.assertIn(" B -> C -> None -> B (actual end-to-end test) - test_cases = [ - (site_b, "Switch from A to B"), - (site_c, "Switch from B to C"), - (None, "Unbind all (C to None)"), - (site_b, "Bind B again (None to B)"), - ] - - for target_site, description in test_cases: - with self.subTest(description=description): - form_data = {"name": self.service.name} - if target_site: - form_data["site_binding"] = target_site.pk - - form = form_cls(form_data, instance=self.service) - self.assertTrue(form.is_valid(), f"Form should be valid for {description}") - form.save() - - # Verify the actual database state after bulk operations - if target_site: - # Verify target site is bound to service - target_site.refresh_from_db() - self.assertEqual(target_site.service, self.service, - f"Target site should be bound to service for {description}") - - # Verify other sites are not bound - other_sites = [s for s in [site_a, site_b, site_c] if s != target_site] - for other_site in other_sites: - other_site.refresh_from_db() - self.assertIsNone(other_site.service, - f"Other sites should not be bound for {description}") - else: - # Verify no sites are bound - for site in [site_a, site_b, site_c]: - site.refresh_from_db() - self.assertIsNone(site.service, - f"No sites should be bound for {description}") - - def test_bulk_single_select_with_no_initial_binding(self): - """Test bulk operations when no initial binding exists.""" - # Create test data - site_a = Site.objects.create(name="Site A") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Bind site_a to service (from no initial binding) - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the actual database state - site_a.refresh_from_db() - self.assertEqual(site_a.service, self.service, "Site should be bound to service") - - def test_bulk_single_select_unbind_all(self): - """Test bulk operations when unbinding all (setting to None).""" - # Create test data - site_a = Site.objects.create(name="Site A") - - # Initially bind site_a to service - site_a.service = self.service - site_a.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Unbind all (set to empty/None) - form = form_cls( - { - "name": self.service.name, - "site_binding": "", # Empty selection - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the actual database state - site_a.refresh_from_db() - self.assertIsNone(site_a.service, "Site should be unbound from service") - - def test_bulk_single_select_no_change_keeps_binding(self): - """Submitting the same selection keeps FK bound when bulk=True (single-select).""" - # Create test data - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B") - - # Initially bind site_a to service - site_a.service = self.service - site_a.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Submit the same selection (no change) - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # site_a remains bound; site_b remains unbound - site_a.refresh_from_db() - site_b.refresh_from_db() - self.assertEqual(site_a.service, self.service) - self.assertIsNone(site_b.service) - - def test_bulk_single_select_change_selection_rebinds(self): - """Changing selection A→B unbinds A and binds B in bulk single-select.""" - # Create test data - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B") - - # Initially bind site_a to service - site_a.service = self.service - site_a.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Change selection from A to B - form = form_cls( - { - "name": self.service.name, - "site_binding": site_b.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify A is unbound and B is bound - site_a.refresh_from_db() - site_b.refresh_from_db() - self.assertIsNone(site_a.service) - self.assertEqual(site_b.service, self.service) - - -class BulkOperationMultiSelectTests(BaseAdminMixinTestCase): - """Test suite for bulk operations with multi-select relationships.""" - - def test_bulk_unbind_multiple_deselected_objects(self): - """Test bulk unbind of multiple deselected objects.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - ext_3 = Extension.objects.create(number="1003") - ext_4 = Extension.objects.create(number="1004") - - # Initially bind ext_1, ext_2, ext_3 to service - for ext in [ext_1, ext_2, ext_3]: - ext.service = self.service - ext.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Change selection to only ext_2 and ext_4 (should unbind ext_1, ext_3 and bind ext_4) - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_2.pk, ext_4.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the actual database state - ext_1.refresh_from_db() - ext_2.refresh_from_db() - ext_3.refresh_from_db() - ext_4.refresh_from_db() - - # ext_1 and ext_3 should be unbound (deselected) - self.assertIsNone(ext_1.service, "ext_1 should be unbound") - self.assertIsNone(ext_3.service, "ext_3 should be unbound") - - # ext_2 and ext_4 should be bound (selected) - self.assertEqual(ext_2.service, self.service, "ext_2 should remain bound") - self.assertEqual(ext_4.service, self.service, "ext_4 should be newly bound") - - def test_bulk_bind_multiple_selected_objects(self): - """Test bulk bind of multiple selected objects.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - ext_3 = Extension.objects.create(number="1003") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Bind multiple extensions to service (from no initial bindings) - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk, ext_3.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the actual database state - ext_1.refresh_from_db() - ext_2.refresh_from_db() - ext_3.refresh_from_db() - - # All extensions should be bound to the service - self.assertEqual(ext_1.service, self.service, "ext_1 should be bound") - self.assertEqual(ext_2.service, self.service, "ext_2 should be bound") - self.assertEqual(ext_3.service, self.service, "ext_3 should be bound") - - def test_complete_multi_select_bulk_workflow_mixed_bind_unbind(self): - """Test complete multi-select bulk workflow with mixed bind/unbind operations.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - ext_3 = Extension.objects.create(number="1003") - ext_4 = Extension.objects.create(number="1004") - ext_5 = Extension.objects.create(number="1005") - - # Initially bind ext_1, ext_2 to service - for ext in [ext_1, ext_2]: - ext.service = self.service - ext.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Test complex workflow with mixed operations - test_cases = [ - ([ext_2.pk, ext_3.pk, ext_4.pk], "Keep ext_2, add ext_3 and ext_4, remove ext_1", - [ext_2, ext_3, ext_4], [ext_1, ext_5]), - ([ext_1.pk, ext_5.pk], "Add ext_1 and ext_5, remove ext_2, ext_3, ext_4", - [ext_1, ext_5], [ext_2, ext_3, ext_4]), - ([], "Remove all extensions", - [], [ext_1, ext_2, ext_3, ext_4, ext_5]), - ([ext_1.pk, ext_2.pk, ext_3.pk, ext_4.pk, ext_5.pk], "Add all extensions", - [ext_1, ext_2, ext_3, ext_4, ext_5], []), - ([ext_3.pk], "Keep only ext_3", - [ext_3], [ext_1, ext_2, ext_4, ext_5]), - ] - - for selected_pks, description, expected_bound, expected_unbound in test_cases: - with self.subTest(description=description): - form_data = {"name": self.service.name} - if selected_pks: - form_data["assigned_extensions"] = selected_pks - - form = form_cls(form_data, instance=self.service) - self.assertTrue(form.is_valid(), f"Form should be valid for {description}") - form.save() - - # Verify the actual database state - for ext in expected_bound: - ext.refresh_from_db() - self.assertEqual(ext.service, self.service, - f"{ext.number} should be bound for {description}") - - for ext in expected_unbound: - ext.refresh_from_db() - self.assertIsNone(ext.service, - f"{ext.number} should be unbound for {description}") - - def test_bulk_multi_select_with_no_changes(self): - """Test bulk operations when selection doesn't change.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - # Initially bind ext_1, ext_2 to service - for ext in [ext_1, ext_2]: - ext.service = self.service - ext.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Submit the same selection (no changes) - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the database state remains unchanged - ext_1.refresh_from_db() - ext_2.refresh_from_db() - - self.assertEqual(ext_1.service, self.service, "ext_1 should remain bound") - self.assertEqual(ext_2.service, self.service, "ext_2 should remain bound") - - def test_bulk_multi_select_partial_overlap(self): - """Test bulk operations with partial overlap between current and new selections.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - ext_3 = Extension.objects.create(number="1003") - ext_4 = Extension.objects.create(number="1004") - - # Initially bind ext_1, ext_2, ext_3 to service - for ext in [ext_1, ext_2, ext_3]: - ext.service = self.service - ext.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Change to ext_2, ext_3, ext_4 (keep ext_2, ext_3; remove ext_1; add ext_4) - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_2.pk, ext_3.pk, ext_4.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the actual database state - ext_1.refresh_from_db() - ext_2.refresh_from_db() - ext_3.refresh_from_db() - ext_4.refresh_from_db() - - # ext_1 should be unbound (removed) - self.assertIsNone(ext_1.service, "ext_1 should be unbound") - - # ext_2 and ext_3 should remain bound (kept) - self.assertEqual(ext_2.service, self.service, "ext_2 should remain bound") - self.assertEqual(ext_3.service, self.service, "ext_3 should remain bound") - - # ext_4 should be newly bound (added) - self.assertEqual(ext_4.service, self.service, "ext_4 should be newly bound") - - def test_bulk_multi_select_empty_to_populated(self): - """Test bulk operations when going from empty selection to populated.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - ext_3 = Extension.objects.create(number="1003") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Go from no selection to multiple selections - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk, ext_3.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the actual database state - ext_1.refresh_from_db() - ext_2.refresh_from_db() - ext_3.refresh_from_db() - - # All extensions should be bound to the service - self.assertEqual(ext_1.service, self.service, "ext_1 should be bound") - self.assertEqual(ext_2.service, self.service, "ext_2 should be bound") - self.assertEqual(ext_3.service, self.service, "ext_3 should be bound") - - def test_bulk_multi_select_populated_to_empty(self): - """Test bulk operations when going from populated selection to empty.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - ext_3 = Extension.objects.create(number="1003") - - # Initially bind all extensions to service - for ext in [ext_1, ext_2, ext_3]: - ext.service = self.service - ext.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Enable bulk operations - ) - } - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Go from multiple selections to no selection - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [], # Empty selection - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the actual database state - ext_1.refresh_from_db() - ext_2.refresh_from_db() - ext_3.refresh_from_db() - - # All extensions should be unbound from the service - self.assertIsNone(ext_1.service, "ext_1 should be unbound") - self.assertIsNone(ext_2.service, "ext_2 should be unbound") - self.assertIsNone(ext_3.service, "ext_3 should be unbound") - - -class BulkOperationRoutingTests(BaseAdminMixinTestCase): - """Test suite for bulk vs individual operation routing functionality.""" - - def test_bulk_false_uses_individual_saves(self): - """Verify bulk=False uses individual saves (existing behavior).""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=False, # Explicitly set to False - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Mock the individual save method to verify it's called - with mock.patch.object(Site, "save") as mock_save: - form = form_cls( - { - "name": self.service.name, # Include required field - "site_binding": site_a.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify individual save was called - mock_save.assert_called() - # Should be called at least once for the bind operation - self.assertGreaterEqual(mock_save.call_count, 1) - - def test_bulk_true_would_use_update_method(self): - """Verify bulk=True configuration would route to bulk operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, # Enable bulk operations - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Since bulk operations aren't implemented yet, we'll test that the config - # is properly set and accessible - config = admin_inst.get_reverse_relations()["site_binding"] - self.assertTrue(config.bulk) - - # Create form to verify it works with bulk=True configuration - form = form_cls( - { - "name": self.service.name, # Include required field - "site_binding": site_a.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - - # For now, this will still use individual saves until bulk methods are implemented - # But the configuration should be accessible for future routing logic - self.assertTrue(hasattr(form, "_reverse_relation_configs")) - self.assertTrue(form._reverse_relation_configs["site_binding"].bulk) - - def test_mixed_bulk_configurations(self): - """Verify mixed configurations (some bulk, some individual) work correctly.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=False, # Individual operations - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Bulk operations - ), - } - - site_a = Site.objects.create(name="Site A") - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - - # Verify configurations are set correctly - relations = admin_inst.get_reverse_relations() - self.assertFalse(relations["site_binding"].bulk) - self.assertTrue(relations["assigned_extensions"].bulk) - - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - { - "name": self.service.name, # Include required field - "site_binding": site_a.pk, - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - - # Verify form has access to both configurations - self.assertFalse(form._reverse_relation_configs["site_binding"].bulk) - self.assertTrue(form._reverse_relation_configs["assigned_extensions"].bulk) - - def test_bulk_configuration_routing_with_mocking(self): - """Use mocking to verify the correct operation type would be called.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Mock the queryset update method to verify bulk operations would be used - with mock.patch.object(Extension.objects, "update"): - with mock.patch.object(Extension, "save") as mock_save: - form = form_cls( - { - "name": self.service.name, # Include required field - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - - # Verify the bulk configuration is accessible - config = form._reverse_relation_configs["assigned_extensions"] - self.assertTrue(config.bulk) - - # For now, individual saves will still be called since bulk methods - # aren't implemented yet, but the configuration is ready for routing - form.save() - - # Individual saves are still called (current implementation) - # Once bulk methods are implemented, mock_update should be called instead - self.assertGreaterEqual(mock_save.call_count, 0) - - def test_backward_compatibility_with_no_bulk_parameter(self): - """Verify existing configurations without bulk parameter work unchanged.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - # No bulk parameter specified - should default to False - ) - } - - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - - # Verify bulk defaults to False - config = admin_inst.get_reverse_relations()["site_binding"] - self.assertFalse(config.bulk) - - form_cls = admin_inst.get_form(request, self.service) - form = form_cls( - { - "name": self.service.name, # Include required field - "site_binding": site_a.pk, - }, - instance=self.service, - ) - - self.assertTrue(form.is_valid()) - form.save() - - # Verify the binding worked (existing functionality) - self.assertEqual(Site.objects.get(pk=site_a.pk).service, self.service) - - def test_clean_hook_blocks_unbind(self): - service = Service.objects.create(name="svc") - a = Site.objects.create(name="A", service=service) - - def forbid_unbind(instance, selection, request): - if selection is None: - raise forms.ValidationError("Cannot unbind site") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - label="Site", - multiple=False, - required=False, - clean=forbid_unbind, - ) - } - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - # Attempt to unbind by passing empty selection - form = form_cls({"name": service.name, "site_binding": ""}, instance=service) - self.assertFalse(form.is_valid()) - self.assertIn("Cannot unbind site", form.errors.get("site_binding", [""])[0]) - # Ensure DB unchanged - self.assertEqual(Site.objects.get(pk=a.pk).service_id, service.pk) - - def test_clean_hook_uses_request_user(self): - service = Service.objects.create(name="svc") - s1 = Site.objects.create(name="A") - - class DummyUser: - def __init__(self, is_staff): - self.is_staff = is_staff - - def staff_only(instance, selection, request): - if not getattr(request, "user", None) or not request.user.is_staff: - raise forms.ValidationError("Not permitted") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - label="Site", - multiple=False, - required=False, - clean=staff_only, - ) - } - - # Non-staff should be blocked - request = self.factory.post("/") - request.user = DummyUser(is_staff=False) - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - form = form_cls({"name": service.name, "site_binding": s1.pk}, instance=service) - self.assertFalse(form.is_valid()) - self.assertIn("Not permitted", form.errors.get("site_binding", [""])[0]) - - # Staff allowed - request2 = self.factory.post("/") - request2.user = DummyUser(is_staff=True) - admin_inst2 = TempAdmin(Service, DummySite()) - form_cls2 = admin_inst2.get_form(request2, service) - form2 = form_cls2({"name": service.name, "site_binding": s1.pk}, instance=service) - self.assertTrue(form2.is_valid()) \ No newline at end of file diff --git a/tests/test_bulk_transactions.py b/tests/test_bulk_transactions.py deleted file mode 100644 index 6f1a1a5..0000000 --- a/tests/test_bulk_transactions.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Test suite for transactional behavior with bulk operations.""" - -# Standard library imports -from unittest import mock - -# Django imports -from django import forms -from django.contrib import admin -from django.db import IntegrityError - -# Project imports -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -# Test imports -from .models import Extension, Service, Site, UniqueExtension -from .shared_test_base import BaseAdminMixinTestCase - - -class BulkOperationTransactionalTests(BaseAdminMixinTestCase): - """Test suite for transactional behavior with bulk operations.""" - - def test_bulk_operations_respect_reverse_relations_atomic_true(self): - """Test that bulk operations respect reverse_relations_atomic=True.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations_atomic = True # Enable atomic transactions - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Mock transaction.atomic to verify it's called - with mock.patch('django.db.transaction.atomic') as mock_atomic: - # Configure the mock to act as a context manager - mock_atomic.return_value.__enter__ = mock.Mock() - mock_atomic.return_value.__exit__ = mock.Mock(return_value=None) - - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that transaction.atomic was called when reverse_relations_atomic=True - mock_atomic.assert_called_once() - - def test_bulk_operations_respect_reverse_relations_atomic_false(self): - """Test that bulk operations respect reverse_relations_atomic=False.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations_atomic = False # Disable atomic transactions - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Mock transaction.atomic to verify it's NOT called - with mock.patch('django.db.transaction.atomic') as mock_atomic: - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that transaction.atomic was NOT called when reverse_relations_atomic=False - mock_atomic.assert_not_called() - - def test_bulk_operations_rollback_on_failure_with_atomic_true(self): - """Test that bulk operations rollback properly when they fail with atomic=True.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations_atomic = True # Enable atomic transactions - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ), - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, - ) - } - - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Mock the second bulk operation to fail, simulating a mid-transaction error - call_count = {'count': 0} - original_apply_bulk_operations = admin_inst._apply_bulk_operations - - def failing_bulk_operations(config, instance, selection): - call_count['count'] += 1 - if call_count['count'] == 2: # Fail on second operation - raise IntegrityError("Simulated failure in second operation") - return original_apply_bulk_operations(config, instance, selection) - - with mock.patch.object( - admin_inst, '_apply_bulk_operations', side_effect=failing_bulk_operations - ): - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], - "site_binding": site_a.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - - # Should raise IntegrityError due to the simulated failure - with self.assertRaises(IntegrityError): - form.save() - - # Verify that no changes persisted due to rollback - # Extensions should not be bound to the service - self.assertEqual(Extension.objects.filter(service=self.service).count(), 0) - # Site should not be bound to the service - self.assertIsNone(Site.objects.get(pk=site_a.pk).service) - - def test_bulk_operations_maintain_data_integrity_during_operations(self): - """Test that bulk operations maintain data integrity during the operation sequence.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations_atomic = True # Enable atomic transactions - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ) - } - - # Create existing extensions bound to the service - existing_ext_1 = Extension.objects.create(number="1001", service=self.service) - existing_ext_2 = Extension.objects.create(number="1002", service=self.service) - - # Create new extensions to bind - new_ext_1 = Extension.objects.create(number="1003") - new_ext_2 = Extension.objects.create(number="1004") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Track the sequence of operations to verify unbind-before-bind ordering - operation_sequence = [] - - original_apply_bulk_unbind = admin_inst._apply_bulk_unbind - original_apply_bulk_bind = admin_inst._apply_bulk_bind - - def track_unbind(config, instance, exclude_pks): - operation_sequence.append(('unbind', exclude_pks)) - return original_apply_bulk_unbind(config, instance, exclude_pks) - - def track_bind(config, instance, target_objects): - operation_sequence.append(('bind', [obj.pk for obj in target_objects])) - return original_apply_bulk_bind(config, instance, target_objects) - - with mock.patch.object(admin_inst, '_apply_bulk_unbind', side_effect=track_unbind): - with mock.patch.object(admin_inst, '_apply_bulk_bind', side_effect=track_bind): - form = form_cls( - { - "name": self.service.name, - # Select only new extensions, should unbind existing ones - "assigned_extensions": [new_ext_1.pk, new_ext_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify the operation sequence maintains unbind-before-bind ordering - self.assertEqual(len(operation_sequence), 2) - self.assertEqual(operation_sequence[0][0], 'unbind') # First: unbind - self.assertEqual(operation_sequence[1][0], 'bind') # Second: bind - - # Verify the correct objects were processed - unbind_exclude_pks = operation_sequence[0][1] - bind_pks = operation_sequence[1][1] - - # Unbind should exclude the new extensions (keep them unbound initially) - self.assertEqual(unbind_exclude_pks, {new_ext_1.pk, new_ext_2.pk}) - # Bind should include the new extensions - self.assertEqual(set(bind_pks), {new_ext_1.pk, new_ext_2.pk}) - - # Verify final state: only new extensions should be bound - bound_extensions = Extension.objects.filter(service=self.service) - self.assertEqual( - set(bound_extensions.values_list('pk', flat=True)), {new_ext_1.pk, new_ext_2.pk} - ) - - # Verify existing extensions were unbound - self.assertIsNone(Extension.objects.get(pk=existing_ext_1.pk).service) - self.assertIsNone(Extension.objects.get(pk=existing_ext_2.pk).service) - - def test_bulk_operations_atomic_behavior_with_mixed_configurations(self): - """Test atomic behavior when mixing bulk and individual operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations_atomic = True # Enable atomic transactions - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Use bulk operations - ), - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=False, # Use individual operations - ) - } - - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Mock the individual operations to fail, simulating a mixed failure scenario - with mock.patch.object( - admin_inst, - '_apply_individual_operations', - side_effect=IntegrityError("Individual operation failed"), - ): - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], # Bulk operation - "site_binding": site_a.pk, # Individual operation (will fail) - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - - # Should raise IntegrityError due to the individual operation failure - with self.assertRaises(IntegrityError): - form.save() - - # Verify that ALL operations were rolled back due to atomic transaction - # Extensions should not be bound (bulk operation should be rolled back too) - self.assertEqual(Extension.objects.filter(service=self.service).count(), 0) - # Site should not be bound - self.assertIsNone(Site.objects.get(pk=site_a.pk).service) - - def test_bulk_operations_data_integrity_with_constraint_violations(self): - """Test data integrity when bulk operations encounter constraint violations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations_atomic = True # Enable atomic transactions - reverse_relations = { - "unique_bindings": ReverseRelationConfig( - model=UniqueExtension, - fk_field="service", - multiple=True, # This will cause constraint violation for OneToOneField - bulk=True, - ) - } - - # Create UniqueExtensions (OneToOneField can only bind one per service) - unique_1 = UniqueExtension.objects.create(number="1001") - unique_2 = UniqueExtension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - form = form_cls( - { - "name": self.service.name, - # Trying to bind multiple objects to OneToOneField should fail - "unique_bindings": [unique_1.pk, unique_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - - # Should raise ValidationError due to constraint violation - with self.assertRaises(forms.ValidationError) as cm: - form.save() - - # Verify meaningful error message - error_message = str(cm.exception) - self.assertIn("Bulk", error_message) - self.assertIn("operation failed", error_message) - - # Verify no partial updates persisted (data integrity maintained) - self.assertIsNone(UniqueExtension.objects.get(pk=unique_1.pk).service) - self.assertIsNone(UniqueExtension.objects.get(pk=unique_2.pk).service) \ No newline at end of file diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py deleted file mode 100644 index 2919e17..0000000 --- a/tests/test_core_functionality.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Core functionality tests for the ReverseRelationAdminMixin. - -This module contains tests for basic admin mixin functionality, form generation, -and field rendering capabilities. -""" -# Standard library imports -from unittest import mock - -# Django imports -from django import forms -from django.contrib import admin -from django.db import IntegrityError -from django.test import RequestFactory - -# Project imports -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -# Test imports -from .admin import ServiceAdmin -from .models import Extension, Service, Site, UniqueExtension -from .shared_test_base import BaseAdminMixinTestCase, DummySite - - -class CoreAdminMixinTests(BaseAdminMixinTestCase): - """Test core admin mixin functionality, form generation, and field rendering.""" - - def test_single_binding_syncs(self): - """Test that single binding synchronization works correctly.""" - service = Service.objects.create(name="svc") - a = Site.objects.create(name="A") - b = Site.objects.create(name="B") - - request = self.factory.post("/") - admin = ServiceAdmin(Service, self.site) - form_cls = admin.get_form(request, service) - - form = form_cls({"site_binding": a.pk}, instance=service) - self.assertTrue(form.is_valid()) - obj = form.save() - self.assertEqual(Site.objects.get(pk=a.pk).service, obj) - self.assertIsNone(Site.objects.get(pk=b.pk).service) - - # Change selection to B; A should unbind - form = form_cls({"site_binding": b.pk}, instance=obj) - self.assertTrue(form.is_valid()) - obj = form.save() - self.assertEqual(Site.objects.get(pk=b.pk).service, obj) - self.assertIsNone(Site.objects.get(pk=a.pk).service) - - def test_admin_without_declared_fieldsets_renders_virtual_fields(self): - """Ensure admins that do not declare fieldsets still render reverse fields. - - get_form should derive fields from ModelForm when no fieldsets are declared, - and our injected reverse fields must still be added. - """ - service = Service.objects.create(name="svc") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - # No fieldsets declared - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - request = self.factory.get("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - # Instantiate the form; the injected field should exist - form = form_cls(instance=service) - self.assertIn("site_binding", form.fields) - - def test_admin_declares_fields_but_not_virtual_names_still_renders(self): - """When admin declares `fields` (no fieldsets), get_fields appends virtual names. - - This ensures the template includes the reverse fields even if the - admin didn't explicitly list them in `fields`. - """ - service = Service.objects.create(name="svc") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - fields = ("name",) # does not include the virtual field - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - request = self.factory.get("/") - admin_inst = TempAdmin(Service, DummySite()) - # get_fields should include the virtual name - names = admin_inst.get_fields(request, service) - self.assertIn("site_binding", names) - # Form should contain the injected field - form_cls = admin_inst.get_form(request, service) - form = form_cls(instance=service) - self.assertIn("site_binding", form.fields) - - def test_multiple_binding_syncs(self): - """Test that multiple binding synchronization works correctly.""" - service = Service.objects.create(name="svc") - e1 = Extension.objects.create(number="1001") - e2 = Extension.objects.create(number="1002") - e3 = Extension.objects.create(number="1003") - - request = self.factory.post("/") - admin = ServiceAdmin(Service, self.site) - form_cls = admin.get_form(request, service) - - # Select e1 and e2 - form = form_cls({"assigned_extensions": [e1.pk, e2.pk]}, instance=service) - self.assertTrue(form.is_valid()) - obj = form.save() - self.assertEqual( - set(Extension.objects.filter(service=obj).values_list("pk", flat=True)), - {e1.pk, e2.pk}, - ) - - # Switch to e2 and e3 (e1 should unbind) - form = form_cls({"assigned_extensions": [e2.pk, e3.pk]}, instance=obj) - self.assertTrue(form.is_valid()) - obj = form.save() - self.assertEqual( - set(Extension.objects.filter(service=obj).values_list("pk", flat=True)), - {e2.pk, e3.pk}, - ) - - def test_multi_select_unique_conflict_rolls_back(self): - """Binding multiple rows to a unique-per-service model should fully rollback.""" - service = Service.objects.create(name="svc") - - # Define a temp admin that exposes a multi-select for a model that has a - # uniqueness constraint on the FK, so selecting 2 will violate unique. - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "unique_bindings": ReverseRelationConfig( - model=UniqueExtension, - fk_field="service", - label="Unique", - multiple=True, - ) - } - - a = UniqueExtension.objects.create(number="1001") - b = UniqueExtension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - form = form_cls({"name": service.name, "unique_bindings": [a.pk, b.pk]}, instance=service) - # This should be invalid because OneToOneField cannot bind two rows to the same service. - # The save should error and rollback. - self.assertTrue(form.is_valid()) - with self.assertRaises(IntegrityError): - form.save() - - # Ensure no partial updates persisted (transaction rolled back) - self.assertIsNone(UniqueExtension.objects.get(pk=a.pk).service) - self.assertIsNone(UniqueExtension.objects.get(pk=b.pk).service) - - def test_mid_update_error_rolls_back(self): - """Simulate an exception during bind sequence; expect zero changes persist.""" - service = Service.objects.create(name="svc") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "unique_bindings": ReverseRelationConfig( - model=UniqueExtension, - fk_field="service", - label="Unique", - multiple=True, - ) - } - - m = UniqueExtension - x = m.objects.create(number="1001") - y = m.objects.create(number="1002") - z = m.objects.create(number="1003") - - call_count = {"n": 0} - - original_save = m.save - - def flaky_save(self, *args, **kwargs): - call_count["n"] += 1 - if call_count["n"] == 2: - raise RuntimeError("Simulated mid-update failure") - return original_save(self, *args, **kwargs) - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - form = form_cls( - {"name": service.name, "unique_bindings": [x.pk, y.pk, z.pk]}, - instance=service, - ) - self.assertTrue(form.is_valid()) - - with mock.patch.object(m, "save", new=flaky_save): - with self.assertRaises(RuntimeError): - form.save() - - # No objects should remain bound due to atomic rollback - self.assertEqual(m.objects.filter(service=service).count(), 0) \ No newline at end of file diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py new file mode 100644 index 0000000..80ec01a --- /dev/null +++ b/tests/test_edge_cases.py @@ -0,0 +1,613 @@ +"""Tests for edge cases and non-parameterizable scenarios.""" + +# Test imports +from django.contrib import admin +from django.db import transaction + +from django_admin_reversefields.mixins import ( + ReverseRelationAdminMixin, + ReverseRelationConfig, +) +from tests.models import Company, CompanySettings, Department, Project +from tests.shared_test_base import BaseAdminMixinTestCase + + +class EdgeCasesTests(BaseAdminMixinTestCase): + """Test suite for edge cases and non-parameterizable scenarios.""" + + def test_large_dataset_performance(self): + """Test base operations with large datasets.""" + # Create a large dataset + large_departments = self.create_large_dataset(100, "departments") + large_projects = self.create_large_dataset(50, "projects") + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + ), + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Test form creation with large dataset (should not timeout) + form = form_cls(instance=self.company) + self.assertIn("project_binding", form.fields) + self.assertIn("assigned_departments", form.fields) + + # Test selecting multiple items from large dataset + selected_departments = [dept.pk for dept in large_departments[:10]] # Select first 10 + selected_project = large_projects[0].pk + + form_with_selection = form_cls( + { + "name": self.company.name, + "project_binding": selected_project, + "assigned_departments": selected_departments, + }, + instance=self.company, + ) + + self.assertTrue(form_with_selection.is_valid()) + saved_company = form_with_selection.save() + + # Verify correct number of bindings + bound_departments = Department.objects.filter(company=saved_company) + self.assertEqual(bound_departments.count(), 10) + + bound_project = Project.objects.get(company=saved_company) + self.assertEqual(bound_project.pk, selected_project) + + def test_maximum_selection_limits(self): + """Test base operations at maximum reasonable selection limits.""" + # Create test data + departments = self.create_test_departments(20) + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Test selecting all available items + all_department_pks = [dept.pk for dept in departments] + form = form_cls( + {"name": self.company.name, "assigned_departments": all_department_pks}, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + saved_company = form.save() + + # Verify all items were bound + bound_count = Department.objects.filter(company=saved_company).count() + self.assertEqual(bound_count, len(departments)) + + def test_filtered_queryset_edge_cases(self): + """Test base operations with heavily filtered querysets.""" + # Create test data with specific patterns + departments = [] + for i in range(10): + dept = Department.objects.create(name=f"Department{i}") + departments.append(dept) + + # Create admin with filtering that excludes most items + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, + limit_choices_to=lambda qs, instance, request: qs.filter( + name__endswith="5" + ), # Only names ending in 5 + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + form = form_cls(instance=self.company) + + # Should only have one choice (Department5) + dept_field = form.fields["assigned_departments"] + if hasattr(dept_field, "queryset"): + filtered_count = dept_field.queryset.count() + self.assertEqual(filtered_count, 1) + + # Test selecting the filtered item + filtered_dept = Department.objects.get(name="Department5") + form_with_selection = form_cls( + {"name": self.company.name, "assigned_departments": [filtered_dept.pk]}, + instance=self.company, + ) + + self.assertTrue(form_with_selection.is_valid()) + saved_company = form_with_selection.save() + + # Verify binding was created + filtered_dept.refresh_from_db() + self.assertEqual(filtered_dept.company, saved_company) + + def test_empty_selection_after_filtering(self): + """Test base operations when filtering results in no available choices.""" + # Create test data + self.create_test_departments(5) + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, + limit_choices_to=lambda qs, instance, request: qs.filter( + name="nonexistent" + ), # Filter that matches nothing + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + form = form_cls(instance=self.company) + + # Should have no choices available + dept_field = form.fields["assigned_departments"] + if hasattr(dept_field, "queryset"): + self.assertEqual(dept_field.queryset.count(), 0) + + # Form should be valid with empty selection + form_with_empty = form_cls( + {"name": self.company.name, "assigned_departments": []}, instance=self.company + ) + self.assertTrue(form_with_empty.is_valid()) + + def test_model_deletion_edge_cases(self): + """Test base operations when related models are deleted during processing.""" + # Create test data + project_a = Project.objects.create(name="Project A") + dept_a = Department.objects.create(name="Department A") + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + ), + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Create form with valid selections + form = form_cls( + { + "name": self.company.name, + "project_binding": project_a.pk, + "assigned_departments": [dept_a.pk], + }, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + + # Delete one of the selected objects before saving + deleted_dept_pk = dept_a.pk + dept_a.delete() + + # Save should handle the missing object gracefully + # The exact behavior depends on implementation, but it shouldn't crash + from django.db.utils import DatabaseError + + try: + saved_company = form.save() + # If save succeeds, verify remaining bindings + project_a.refresh_from_db() + self.assertEqual(project_a.company, saved_company) + # Verify the deleted department is not bound + self.assertEqual(Department.objects.filter(pk=deleted_dept_pk).count(), 0) + except (Department.DoesNotExist, DatabaseError): + # If save fails due to missing object or database error, that's acceptable behavior + # The important thing is that it doesn't crash unexpectedly + pass + + def test_concurrent_model_modifications(self): + """Test base operations with concurrent model modifications.""" + # Create test data + project_a = Project.objects.create(name="Project A") + dept_a = Department.objects.create(name="Department A") + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + ), + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Create form + form = form_cls( + { + "name": self.company.name, + "project_binding": project_a.pk, + "assigned_departments": [dept_a.pk], + }, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + + # Simulate concurrent modification: another process binds the objects + concurrent_company = Company.objects.create(name="concurrent-company") + project_a.company = concurrent_company + project_a.save() + dept_a.company = concurrent_company + dept_a.save() + + # Our form save should still work (unbind from concurrent, bind to ours) + saved_company = form.save() + + # Verify final state + project_a.refresh_from_db() + dept_a.refresh_from_db() + self.assertEqual(project_a.company, saved_company) + self.assertEqual(dept_a.company, saved_company) + + def test_transaction_rollback_scenarios(self): + """Test base operations with transaction rollback scenarios.""" + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "settings_binding": ReverseRelationConfig( + model=CompanySettings, + fk_field="company", + multiple=False, + ), + } + + # Create test data + settings_a = CompanySettings.objects.create(timezone="UTC") + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Create a form that should succeed + form = form_cls( + {"name": self.company.name, "settings_binding": settings_a.pk}, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + + # Use transaction to test rollback behavior + try: + with transaction.atomic(): + saved_company = form.save() + + # Verify binding was created + settings_a.refresh_from_db() + self.assertEqual(settings_a.company, saved_company) + + # Force a rollback by raising an exception + raise RuntimeError("Force rollback") + + except RuntimeError: + pass # Expected + + # After rollback, binding should not exist + settings_a.refresh_from_db() + self.assertIsNone(settings_a.company) + + def test_multiple_companies_complex_bindings(self): + """Test base operations with multiple companies and complex binding patterns.""" + # Create multiple companies and objects + company_a = Company.objects.create(name="company-a") + company_b = Company.objects.create(name="company-b") + + # Create objects with mixed binding states + project_1 = Project.objects.create(name="Project 1", company=company_a) + project_2 = Project.objects.create(name="Project 2") # Unbound + project_3 = Project.objects.create(name="Project 3", company=company_b) + + dept_1 = Department.objects.create(name="Department 1", company=company_a) + dept_2 = Department.objects.create(name="Department 2", company=company_b) + dept_3 = Department.objects.create(name="Department 3") # Unbound + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + ), + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Select objects from different companies and unbound objects + form = form_cls( + { + "name": self.company.name, + "project_binding": project_3.pk, # From company_b + "assigned_departments": [dept_1.pk, dept_2.pk, dept_3.pk], # Mixed sources + }, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + saved_company = form.save() + + # Verify all objects were transferred to our company + project_3.refresh_from_db() + dept_1.refresh_from_db() + dept_2.refresh_from_db() + dept_3.refresh_from_db() + + self.assertEqual(project_3.company, saved_company) + self.assertEqual(dept_1.company, saved_company) + self.assertEqual(dept_2.company, saved_company) + self.assertEqual(dept_3.company, saved_company) + + # Verify other companies lost their bindings + self.assertEqual(Project.objects.filter(company=company_a).count(), 1) # project_1 remains + self.assertEqual(Project.objects.filter(company=company_b).count(), 0) # project_3 moved + self.assertEqual(Department.objects.filter(company=company_a).count(), 0) # dept_1 moved + self.assertEqual(Department.objects.filter(company=company_b).count(), 0) # dept_2 moved + + def test_circular_relationship_scenarios(self): + """Test base operations with potential circular relationship scenarios.""" + # Create a complex scenario where companies could reference each other indirectly + company_a = Company.objects.create(name="company-a") + company_b = Company.objects.create(name="company-b") + + # Create projects that reference different companies + project_a = Project.objects.create(name="Project A", company=company_a) + project_b = Project.objects.create(name="Project B", company=company_b) + + # Create departments that reference different companies + dept_a = Department.objects.create(name="Department A", company=company_a) + dept_b = Department.objects.create(name="Department B", company=company_b) + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, + ), + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + + # Test moving objects from company_a to company_b + form_cls_b = admin_inst.get_form(request, company_b) + form_b = form_cls_b( + { + "name": company_b.name, + "project_binding": project_a.pk, # Move from company_a + "assigned_departments": [dept_a.pk, dept_b.pk], # Keep dept_b, add dept_a + }, + instance=company_b, + ) + + self.assertTrue(form_b.is_valid()) + form_b.save() + + # Verify transfers + project_a.refresh_from_db() + dept_a.refresh_from_db() + dept_b.refresh_from_db() + + self.assertEqual(project_a.company, company_b) + self.assertEqual(dept_a.company, company_b) + self.assertEqual(dept_b.company, company_b) + + # Now test moving objects back to company_a + form_cls_a = admin_inst.get_form(request, company_a) + form_a = form_cls_a( + { + "name": company_a.name, + "project_binding": project_b.pk, # Move from company_b + "assigned_departments": [dept_a.pk], # Move back dept_a + }, + instance=company_a, + ) + + self.assertTrue(form_a.is_valid()) + form_a.save() + + # Verify final state + project_a.refresh_from_db() + project_b.refresh_from_db() + dept_a.refresh_from_db() + dept_b.refresh_from_db() + + self.assertEqual(project_a.company, company_b) # Still with company_b + self.assertEqual(project_b.company, company_a) # Moved to company_a + self.assertEqual(dept_a.company, company_a) # Moved back to company_a + self.assertEqual(dept_b.company, company_b) # Remains with company_b + + def test_mixed_relationship_types(self): + """Test base operations with mixed relationship types (ForeignKey and OneToOne).""" + # Create test data + project_a = Project.objects.create(name="Project A") + dept_a = Department.objects.create(name="Department A") + settings_a = CompanySettings.objects.create(timezone="UTC") + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "project_binding": ReverseRelationConfig( + model=Project, + fk_field="company", + multiple=False, # Single ForeignKey + ), + "assigned_departments": ReverseRelationConfig( + model=Department, + fk_field="company", + multiple=True, # Multiple ForeignKey + ), + "settings_binding": ReverseRelationConfig( + model=CompanySettings, + fk_field="company", + multiple=False, # OneToOne relationship + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Test binding all relationship types + form = form_cls( + { + "name": self.company.name, + "project_binding": project_a.pk, + "assigned_departments": [dept_a.pk], + "settings_binding": settings_a.pk, + }, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + saved_company = form.save() + + # Verify all bindings were created + project_a.refresh_from_db() + dept_a.refresh_from_db() + settings_a.refresh_from_db() + + self.assertEqual(project_a.company, saved_company) + self.assertEqual(dept_a.company, saved_company) + self.assertEqual(settings_a.company, saved_company) + + # Test partial unbinding (keep settings, unbind others) + form_partial = form_cls( + { + "name": self.company.name, + "project_binding": "", + "assigned_departments": [], + "settings_binding": settings_a.pk, # Keep this one + }, + instance=self.company, + ) + + self.assertTrue(form_partial.is_valid()) + form_partial.save() + + # Verify partial unbinding + project_a.refresh_from_db() + dept_a.refresh_from_db() + settings_a.refresh_from_db() + + self.assertIsNone(project_a.company) # Unbound + self.assertIsNone(dept_a.company) # Unbound + self.assertEqual(settings_a.company, saved_company) # Still bound + + def test_relationship_constraint_interactions(self): + """Test base operations with complex constraint interactions.""" + # Create companies and settings + company_a = Company.objects.create(name="company-a") + company_b = Company.objects.create(name="company-b") + + settings_1 = CompanySettings.objects.create(timezone="UTC", company=company_a) + settings_2 = CompanySettings.objects.create(timezone="EST", company=company_b) + settings_3 = CompanySettings.objects.create(timezone="PST") # Unbound + + class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): + reverse_relations = { + "settings_binding": ReverseRelationConfig( + model=CompanySettings, + fk_field="company", + multiple=False, + ), + } + + request = self.factory.post("/") + admin_inst = TestAdmin(Company, self.site) + form_cls = admin_inst.get_form(request, self.company) + + # Test transferring settings from another company + form = form_cls( + {"name": self.company.name, "settings_binding": settings_1.pk}, + instance=self.company, + ) + + self.assertTrue(form.is_valid()) + saved_company = form.save() + + # Verify transfer (should unbind from company_a, bind to our company) + settings_1.refresh_from_db() + self.assertEqual(settings_1.company, saved_company) + + # Verify company_a lost its settings + company_a.refresh_from_db() + with self.assertRaises(CompanySettings.DoesNotExist): + _ = company_a.settings + + # Test switching to a different settings + form_switch = form_cls( + {"name": self.company.name, "settings_binding": settings_2.pk}, + instance=self.company, + ) + + self.assertTrue(form_switch.is_valid()) + form_switch.save() + + # Verify switch (settings_1 should be unbound, settings_2 should be bound) + settings_1.refresh_from_db() + settings_2.refresh_from_db() + + self.assertIsNone(settings_1.company) # Unbound + self.assertEqual(settings_2.company, saved_company) # Bound + + # Verify company_b lost its settings + company_b.refresh_from_db() + with self.assertRaises(CompanySettings.DoesNotExist): + _ = company_b.settings diff --git a/tests/test_parameterized_operations.py b/tests/test_parameterized_operations.py deleted file mode 100644 index 0cdd571..0000000 --- a/tests/test_parameterized_operations.py +++ /dev/null @@ -1,518 +0,0 @@ -"""Parameterized tests to ensure feature parity between bulk and non-bulk operations. - -This module contains tests that verify the same logical operations work consistently -in both bulk=True and bulk=False modes, ensuring feature parity between operation modes. -""" - -# Django imports -from django.contrib import admin - -# Project imports -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -# Test imports -from .models import Extension, Service, Site -from .shared_test_base import BaseAdminMixinTestCase - - -class ParameterizedOperationTests(BaseAdminMixinTestCase): - """Test core operations with both bulk=True and bulk=False parameters.""" - - def test_single_select_binding_both_modes(self): - """Test single-select binding works consistently in both bulk and non-bulk modes.""" - # Create test data - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B") - - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - # Create fresh service for each test - service = Service.objects.create(name=f"test-service-{bulk_enabled}") - - admin_instance = self.create_parameterized_admin(bulk_enabled=bulk_enabled) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, service) - - # Test initial binding - form = form_cls({"site_binding": site_a.pk}, instance=service) - self.assertTrue(form.is_valid(), - f"Form should be valid for bulk={bulk_enabled}") - obj = form.save() - - # Verify binding worked - site_a.refresh_from_db() - self.assertEqual(site_a.service, obj, - f"Site A should be bound for bulk={bulk_enabled}") - - # Test changing binding - form = form_cls({"site_binding": site_b.pk}, instance=obj) - self.assertTrue(form.is_valid(), - f"Form should be valid for rebinding with bulk={bulk_enabled}") - obj = form.save() - - # Verify rebinding worked - site_a.refresh_from_db() - site_b.refresh_from_db() - self.assertIsNone(site_a.service, - f"Site A should be unbound for bulk={bulk_enabled}") - self.assertEqual(site_b.service, obj, - f"Site B should be bound for bulk={bulk_enabled}") - - def test_multiple_select_binding_both_modes(self): - """Test multi-select binding works consistently in both bulk and non-bulk modes.""" - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - # Create fresh test data for each iteration - ext_1 = Extension.objects.create(number=f"100{bulk_enabled}1") - ext_2 = Extension.objects.create(number=f"100{bulk_enabled}2") - ext_3 = Extension.objects.create(number=f"100{bulk_enabled}3") - service = Service.objects.create(name=f"test-service-multi-{bulk_enabled}") - - admin_instance = self.create_parameterized_admin(bulk_enabled=bulk_enabled) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, service) - - # Test initial multi-binding - form = form_cls( - {"assigned_extensions": [ext_1.pk, ext_2.pk]}, - instance=service - ) - self.assertTrue(form.is_valid(), - f"Form should be valid for multi-select with bulk={bulk_enabled}") - obj = form.save() - - # Verify multi-binding worked - ext_1.refresh_from_db() - ext_2.refresh_from_db() - ext_3.refresh_from_db() - self.assertEqual(ext_1.service, obj, - f"Extension 1 should be bound for bulk={bulk_enabled}") - self.assertEqual(ext_2.service, obj, - f"Extension 2 should be bound for bulk={bulk_enabled}") - self.assertIsNone(ext_3.service, - f"Extension 3 should be unbound for bulk={bulk_enabled}") - - # Test changing multi-selection - form = form_cls( - {"assigned_extensions": [ext_2.pk, ext_3.pk]}, - instance=obj - ) - self.assertTrue(form.is_valid(), - f"Form should be valid for multi-rebinding with bulk={bulk_enabled}") - obj = form.save() - - # Verify multi-rebinding worked - ext_1.refresh_from_db() - ext_2.refresh_from_db() - ext_3.refresh_from_db() - self.assertIsNone(ext_1.service, - f"Extension 1 should be unbound for bulk={bulk_enabled}") - self.assertEqual(ext_2.service, obj, - f"Extension 2 should remain bound for bulk={bulk_enabled}") - self.assertEqual(ext_3.service, obj, - f"Extension 3 should be newly bound for bulk={bulk_enabled}") - - def test_empty_selection_handling_both_modes(self): - """Test empty selection handling works consistently in both modes.""" - # Create test data - site_a = Site.objects.create(name="Site A") - ext_1 = Extension.objects.create(number="1001") - - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - # Create fresh service for each test - service = Service.objects.create(name=f"test-service-empty-{bulk_enabled}") - - # Initially bind objects - site_a.service = service - site_a.save() - ext_1.service = service - ext_1.save() - - admin_instance = self.create_parameterized_admin(bulk_enabled=bulk_enabled) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, service) - - # Test clearing single-select - form = form_cls({"site_binding": ""}, instance=service) - self.assertTrue(form.is_valid(), - f"Form should be valid for empty single-select with bulk={bulk_enabled}") - obj = form.save() - - # Verify unbinding worked - site_a.refresh_from_db() - self.assertIsNone(site_a.service, - f"Site should be unbound for bulk={bulk_enabled}") - - # Test clearing multi-select - form = form_cls({"assigned_extensions": []}, instance=obj) - self.assertTrue(form.is_valid(), - f"Form should be valid for empty multi-select with bulk={bulk_enabled}") - obj = form.save() - - # Verify multi-unbinding worked - ext_1.refresh_from_db() - self.assertIsNone(ext_1.service, - f"Extension should be unbound for bulk={bulk_enabled}") - - def test_form_field_generation_both_modes(self): - """Test form field generation works consistently in both modes.""" - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - admin_instance = self.create_parameterized_admin(bulk_enabled=bulk_enabled) - - request = self.factory.get("/") - form_cls = admin_instance.get_form(request, self.service) - form = form_cls(instance=self.service) - - # Verify fields exist - self.assertIn("site_binding", form.fields, - f"site_binding field should exist for bulk={bulk_enabled}") - self.assertIn("assigned_extensions", form.fields, - f"assigned_extensions field should exist for bulk={bulk_enabled}") - - # Verify field properties - site_field = form.fields["site_binding"] - ext_field = form.fields["assigned_extensions"] - - # Single-select field should not be multiple - self.assertFalse(getattr(site_field.widget, "allow_multiple_selected", True), - f"Site field should be single-select for bulk={bulk_enabled}") - - # Multi-select field should allow multiple - self.assertTrue(getattr(ext_field.widget, "allow_multiple_selected", False), - f"Extension field should be multi-select for bulk={bulk_enabled}") - - -class ParameterizedPermissionTests(BaseAdminMixinTestCase): - """Test permission scenarios work consistently in both bulk and non-bulk modes.""" - - def test_permission_policy_consistency_both_modes(self): - """Test permission policies work consistently in both modes.""" - # Create test data - site_a = Site.objects.create(name="Site A") - - # Test different permission scenarios - permission_scenarios = [ - ("allow_all", True), - ("deny_all", False), - ("staff_only", True), # We'll test with staff user - ] - - # Create users once for all test iterations to avoid unique constraint violations - from django.contrib.auth.models import User - regular_user = User.objects.create_user( - username="regular_perm_test", password="test", is_staff=False - ) - staff_user = User.objects.create_user( - username="staff_perm_test", password="test", is_staff=True - ) - - for policy_type, expected_access in permission_scenarios: - for bulk_enabled in [False, True]: - with self.subTest(policy_type=policy_type, bulk_enabled=bulk_enabled): - # Create permission policy - if policy_type == "allow_all": - policy = lambda request, obj, config, selection: True - elif policy_type == "deny_all": - policy = lambda request, obj, config, selection: False - elif policy_type == "staff_only": - policy = lambda request, obj, config, selection: getattr(request.user, "is_staff", False) - else: - raise ValueError(f"Unknown policy_type: {policy_type}") - - # Create admin with permission policy - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=bulk_enabled, - permission=policy - ) - } - - admin_instance = TestAdmin(Service, self.site) - - # Test with staff user (should have access for staff_only policy) - request = self.factory.get("/") - request.user = staff_user - - form_cls = admin_instance.get_form(request, self.service) - form = form_cls(instance=self.service) - - # Field should exist regardless of permission (permissions affect behavior, not existence) - self.assertIn("site_binding", form.fields, - f"Field should exist for {policy_type} with bulk={bulk_enabled}") - - def test_permission_callable_consistency_both_modes(self): - """Test permission callables work consistently in both modes.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - - def custom_permission(request, obj, config, selection): - """Custom permission that allows access only for specific users.""" - return hasattr(request.user, "username") and "staff" in request.user.username - - # Create users once to avoid unique constraint violations - from django.contrib.auth.models import User - staff_user = User.objects.create_user( - username="staff_callable_test", password="test", is_staff=True - ) - - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - - # Create admin with permission callable - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=bulk_enabled, - permission=custom_permission - ) - } - - admin_instance = TestAdmin(Service, self.site) - - # Test with staff user (should have access) - request = self.factory.get("/") - request.user = staff_user - - form_cls = admin_instance.get_form(request, self.service) - form = form_cls(instance=self.service) - - # Field should exist for staff user - self.assertIn("assigned_extensions", form.fields, - f"Field should exist for staff user with bulk={bulk_enabled}") - - def test_permission_mode_consistency_both_modes(self): - """Test permission modes (hide/disable) work consistently in both modes.""" - # Create test data - site_a = Site.objects.create(name="Site A") - - # Create users once to avoid unique constraint violations - from django.contrib.auth.models import User - regular_user = User.objects.create_user( - username="regular_mode_test", password="test", is_staff=False - ) - - # Create deny-all policy - deny_policy = lambda request, obj, config, selection: False - - # Test both permission modes - for permission_mode in ["hide", "disable"]: - for bulk_enabled in [False, True]: - with self.subTest(permission_mode=permission_mode, bulk_enabled=bulk_enabled): - - # Create admin with permission mode - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = permission_mode - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=bulk_enabled, - permission=deny_policy - ) - } - - admin_instance = TestAdmin(Service, self.site) - - # Test with regular user (should be denied) - request = self.factory.get("/") - request.user = regular_user - - form_cls = admin_instance.get_form(request, self.service) - form = form_cls(instance=self.service) - - if permission_mode == "hide": - # Field should not exist when hidden - self.assertNotIn("site_binding", form.fields, - f"Field should be hidden for {permission_mode} with bulk={bulk_enabled}") - else: # disable mode - # Field should exist but be disabled - self.assertIn("site_binding", form.fields, - f"Field should exist but be disabled for {permission_mode} with bulk={bulk_enabled}") - - -class ParameterizedValidationTests(BaseAdminMixinTestCase): - """Test validation scenarios work consistently in both bulk and non-bulk modes.""" - - def test_required_field_validation_both_modes(self): - """Test required field validation works consistently in both modes.""" - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - # Create admin with required field - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=bulk_enabled, - required=True - ) - } - - admin_instance = TestAdmin(Service, self.site) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, self.service) - - # Test with empty required field - form = form_cls({"site_binding": ""}, instance=self.service) - - # Form should be invalid for required field - self.assertFalse(form.is_valid(), - f"Form should be invalid for empty required field with bulk={bulk_enabled}") - self.assertIn("site_binding", form.errors, - f"site_binding should have validation error with bulk={bulk_enabled}") - - def test_invalid_pk_validation_both_modes(self): - """Test invalid primary key validation works consistently in both modes.""" - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - admin_instance = self.create_parameterized_admin(bulk_enabled=bulk_enabled) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, self.service) - - # Test with invalid primary key - form = form_cls({"site_binding": 99999}, instance=self.service) - - # Form should be invalid for non-existent PK - self.assertFalse(form.is_valid(), - f"Form should be invalid for non-existent PK with bulk={bulk_enabled}") - - def test_invalid_selection_validation_both_modes(self): - """Test invalid selection validation works consistently in both modes.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - admin_instance = self.create_parameterized_admin(bulk_enabled=bulk_enabled) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, self.service) - - # Test with mixed valid and invalid PKs in multi-select - form = form_cls( - {"assigned_extensions": [ext_1.pk, 99999]}, - instance=self.service - ) - - # Form should be invalid for mixed valid/invalid PKs - self.assertFalse(form.is_valid(), - f"Form should be invalid for mixed valid/invalid PKs with bulk={bulk_enabled}") - - def test_constraint_violation_handling_both_modes(self): - """Test constraint violation handling works consistently in both modes.""" - # This test verifies that constraint violations are handled the same way - # regardless of bulk mode (both should raise IntegrityError and rollback) - - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - # Create fresh service for each test - service = Service.objects.create(name=f"constraint-test-{bulk_enabled}") - - # Create admin that would cause constraint violation - # (This is a conceptual test - actual constraint depends on model setup) - admin_instance = self.create_parameterized_admin(bulk_enabled=bulk_enabled) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, service) - - # Create valid form (constraint violations would be tested with specific model constraints) - form = form_cls({}, instance=service) - - # Form should be valid (no constraint violation in this basic case) - self.assertTrue(form.is_valid(), - f"Basic form should be valid with bulk={bulk_enabled}") - - def test_validation_error_messages_both_modes(self): - """Test validation error messages are consistent in both modes.""" - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - # Create admin with required field - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=bulk_enabled, - required=True - ) - } - - admin_instance = TestAdmin(Service, self.site) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, self.service) - - # Test with empty required field - form = form_cls({"site_binding": ""}, instance=self.service) - - if not form.is_valid(): - # Error messages should be consistent regardless of bulk mode - self.assertIn("site_binding", form.errors, - f"site_binding should have error with bulk={bulk_enabled}") - - # Error message should be meaningful - error_msg = str(form.errors["site_binding"]) - self.assertGreater(len(error_msg), 0, - f"Error message should not be empty with bulk={bulk_enabled}") - - def test_form_save_validation_both_modes(self): - """Test form save validation works consistently in both modes.""" - # Create test data - site_a = Site.objects.create(name="Site A") - - # Test both bulk modes - for bulk_enabled in [False, True]: - with self.subTest(bulk_enabled=bulk_enabled): - # Create fresh service for each test - service = Service.objects.create(name=f"save-test-{bulk_enabled}") - - admin_instance = self.create_parameterized_admin(bulk_enabled=bulk_enabled) - - request = self.factory.post("/") - form_cls = admin_instance.get_form(request, service) - - # Test valid form save - form = form_cls({"site_binding": site_a.pk}, instance=service) - self.assertTrue(form.is_valid(), - f"Valid form should be valid with bulk={bulk_enabled}") - - # Save should work without errors - try: - saved_obj = form.save() - self.assertEqual(saved_obj, service, - f"Saved object should be the service with bulk={bulk_enabled}") - except Exception as e: - self.fail(f"Form save should not raise exception with bulk={bulk_enabled}: {e}") \ No newline at end of file diff --git a/tests/test_permissions.py b/tests/test_permissions.py deleted file mode 100644 index 829a3af..0000000 --- a/tests/test_permissions.py +++ /dev/null @@ -1,699 +0,0 @@ -"""Test suite for permission handling and policies.""" - -# Standard library imports -from unittest import mock - -# Django imports -from django import forms -from django.contrib import admin -from django.core.exceptions import PermissionDenied - -# Project imports -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -# Test imports -from .models import Extension, Service, Site -from .shared_test_base import BaseAdminMixinTestCase, DummySite - - -class PermissionTests(BaseAdminMixinTestCase): - """Test suite for permission handling and policies.""" - - def test_bulk_operations_respect_reverse_permission_policy(self): - """Test that bulk operations respect ReversePermissionPolicy.""" - - class TestPermissionPolicy: - """Test permission policy that denies access.""" - permission_denied_message = "Access denied by test policy" - - def has_perm(self, request, obj, config, selection): - # Deny access for testing - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_policy = TestPermissionPolicy() - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - - # Mock has_reverse_change_permission to return False (permission denied) - with mock.patch.object(admin_inst, 'has_reverse_change_permission', return_value=False): - form.save() - - # Verify that no bulk operations were performed due to permission denial - # Extensions should not be bound to the service - self.assertEqual(Extension.objects.filter(service=self.service).count(), 0) - - def test_revoked_reverse_field_permission_preserves_existing_bindings(self): - """Existing bindings remain when a field is removed from the authorized payload.""" - - for bulk in (False, True): - with self.subTest(bulk=bulk): - site = Site.objects.create(name=f"Site bulk {int(bulk)}", service=self.service) - - class PermissionGuardAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_render_uses_field_policy = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=bulk, - ) - } - - def has_reverse_change_permission( # type: ignore[override] - self, - request, - obj, - config, - selection=None, - ) -> bool: - return False - - request = self.factory.post("/") - admin_inst = PermissionGuardAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - form = form_cls({"name": self.service.name}, instance=self.service) - self.assertTrue(form.is_valid()) - form.save() - - site.refresh_from_db() - self.assertEqual(site.service, self.service) - - def test_bulk_operations_permission_checks_applied_before_operations(self): - """Test that permission checks are applied before bulk operations.""" - - class TestPermissionPolicy: - """Test permission policy that allows access.""" - - def has_perm(self, request, obj, config, selection): - # Allow access for testing - return True - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_policy = TestPermissionPolicy() - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Track permission check calls - permission_check_calls = [] - original_has_permission = admin_inst.has_reverse_change_permission - - def track_permission_check(request, obj, config, selection=None): - permission_check_calls.append((config.model.__name__, selection)) - return original_has_permission(request, obj, config, selection) - - with mock.patch.object( - admin_inst, 'has_reverse_change_permission', side_effect=track_permission_check - ): - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that permission checks were called before bulk operations - self.assertGreater(len(permission_check_calls), 0, - "Permission checks should be called before bulk operations") - - # Verify the permission check was called for the Extension model - extension_checks = [call for call in permission_check_calls if call[0] == 'Extension'] - self.assertGreater(len(extension_checks), 0, - "Permission check should be called for Extension model") - - def test_bulk_operations_permission_denied_scenarios(self): - """Test permission-denied scenarios with bulk operations.""" - - class DenyAllPolicy: - """Permission policy that denies all access.""" - permission_denied_message = "Bulk operations not allowed" - - def has_perm(self, request, obj, config, selection): - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_policy = DenyAllPolicy() - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ), - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk], - "site_binding": site_a.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that no bulk operations were performed due to permission denial - # Extension should not be bound to the service - self.assertEqual(Extension.objects.filter(service=self.service).count(), 0) - # Site should not be bound to the service - self.assertIsNone(Site.objects.get(pk=site_a.pk).service) - - def test_bulk_operations_with_per_field_permission_policy(self): - """Test bulk operations with per-field permission policies.""" - - class AllowAllPolicy: - """Policy that allows all operations.""" - - def has_perm(self, request, obj, config, selection): - return True - - class DenyAllPolicy: - """Policy that denies all operations.""" - permission_denied_message = "Operations not allowed" - - def has_perm(self, request, obj, config, selection): - return False - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_render_uses_field_policy = True # Enable per-field policies for rendering - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - permission=AllowAllPolicy(), - ), - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, - permission=DenyAllPolicy(), - ) - } - - ext_1 = Extension.objects.create(number="1001") - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Create form instance to check field permissions - form = form_cls(instance=self.service) - - # Verify that extensions field is enabled (permission allowed) - self.assertFalse(form.fields["assigned_extensions"].disabled) - - # Verify that site_binding field is disabled (permission denied) - self.assertTrue(form.fields["site_binding"].disabled) - - # Test form with only allowed field data (disabled fields should be ignored) - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk], - # Don't include site_binding data since it's disabled - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that extensions were allowed (bulk operation succeeded) - self.assertEqual(Extension.objects.filter(service=self.service).count(), 1) - self.assertEqual(Extension.objects.get(pk=ext_1.pk).service, self.service) - - # Verify that site binding was not affected (field was disabled) - self.assertIsNone(Site.objects.get(pk=site_a.pk).service) - - def test_bulk_operations_with_callable_permission_policy(self): - """Test bulk operations with callable permission policies.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - - @staticmethod - def allow_extensions_only(request, obj, config, selection): - """Callable permission policy that allows only extensions.""" - return config.model == Extension - - reverse_permission_policy = allow_extensions_only - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ), - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - site_a = Site.objects.create(name="Site A") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk], - "site_binding": site_a.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that extensions were allowed (bulk operation succeeded) - self.assertEqual(Extension.objects.filter(service=self.service).count(), 1) - self.assertEqual(Extension.objects.get(pk=ext_1.pk).service, self.service) - - # Verify that site binding was denied (bulk operation blocked) - self.assertIsNone(Site.objects.get(pk=site_a.pk).service) - - def test_bulk_operations_with_object_has_perm_policy(self): - """Test bulk operations with object that has has_perm method.""" - - class ObjectWithHasPerm: - """Object with has_perm method for permission checking.""" - - def has_perm(self, request, obj, config, selection): - # Allow all operations for testing - return True - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_policy = ObjectWithHasPerm() - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Track has_perm calls - has_perm_calls = [] - original_has_perm = admin_inst.reverse_permission_policy.has_perm - - def track_has_perm(request, obj, config, selection): - has_perm_calls.append((config.model.__name__, selection)) - return original_has_perm(request, obj, config, selection) - - with mock.patch.object( - admin_inst.reverse_permission_policy, 'has_perm', side_effect=track_has_perm - ): - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that has_perm was called - self.assertGreater(len(has_perm_calls), 0, - "has_perm should be called for permission checking") - - # Verify that bulk operation succeeded - self.assertEqual(Extension.objects.filter(service=self.service).count(), 1) - self.assertEqual(Extension.objects.get(pk=ext_1.pk).service, self.service) - - def test_permissions_disable_mode_disables_field_and_ignores_post(self): - """Test that disable mode disables field and ignores POST data.""" - service = Service.objects.create(name="svc") - a = Site.objects.create(name="A") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permission_mode = "disable" - reverse_permissions_enabled = True - reverse_render_uses_field_policy = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - required=True, - permission_denied_message="You do not have permission to choose this value.", - ) - } - - def has_reverse_change_permission(self, request, obj, config, selection=None) -> bool: # type: ignore[override] - return False - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - form = form_cls({"name": service.name, "site_binding": a.pk}, instance=service) - # Field should be present but disabled; previously, required=True could have caused - # a validation error. Our mixin sets required=False when disabling, so it's valid. - self.assertTrue(form.is_valid()) - form.save() - self.assertIsNone(Site.objects.get(pk=a.pk).service) - - def test_permissions_disable_mode_required_field_without_initial_would_error_without_fix(self): - """Demonstrate that a disabled required field with no initial would be invalid without fix. - - With the mixin's change (required=False when disabling), the form should now be valid. - """ - service = Service.objects.create(name="svc") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permission_mode = "disable" - reverse_permissions_enabled = True - # Force denial at render gate so the field is actually disabled - reverse_render_uses_field_policy = True - reverse_permission_policy = staticmethod(lambda request, obj, config, selection: False) - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - required=True, - ) - } - - def has_reverse_change_permission(self, request, obj, config, selection=None) -> bool: # type: ignore[override] - return False - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - # No initial binding exists. Post no selection. - form = form_cls({"name": service.name, "site_binding": ""}, instance=service) - # Without the fix, Django would raise "This field is required." on a disabled field. - # With the fix, the form should be valid. - self.assertTrue(form.is_valid()) - - def test_permissions_hide_mode_removes_field(self): - """Test that hide mode removes field from form.""" - service = Service.objects.create(name="svc") - a = Site.objects.create(name="A") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permission_mode = "hide" - reverse_permissions_enabled = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - def has_reverse_change_permission(self, request, obj, config, selection=None) -> bool: # type: ignore[override] - return False - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - form = form_cls({"name": service.name, "site_binding": a.pk}, instance=service) - # Field is not in the form (hidden); still valid and ignored - self.assertTrue(form.is_valid()) - form.save() - self.assertIsNone(Site.objects.get(pk=a.pk).service) - - def test_permissions_unsupported_mode_is_handled_gracefully(self): - """Test that unsupported permission modes are handled gracefully.""" - service = Service.objects.create(name="svc") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permission_mode = "raise" - reverse_permissions_enabled = True - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - def has_reverse_change_permission(self, request, obj, config, selection=None) -> bool: # type: ignore[override] - return False - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - # 'raise' is not a supported mode; current behavior treats it like disable/hide. - admin_inst.get_form(request, service) - - def test_per_field_permission_callable_denies(self): - """Test per-field permission callable that denies access.""" - service = Service.objects.create(name="svc") - a = Site.objects.create(name="A") - - def deny_policy(request, obj, config, selection): - return False - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=None, - permission=deny_policy, - ) - } - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - form = form_cls({"name": service.name, "site_binding": a.pk}, instance=service) - self.assertFalse(form.is_valid()) - self.assertIn("permission", form.errors.get("site_binding", [""])[0].lower()) - # No change persisted - self.assertIsNone(Site.objects.get(pk=a.pk).service) - - def test_per_field_permission_object_selection_based(self): - """Test per-field permission based on object selection.""" - service = Service.objects.create(name="svc") - a = Site.objects.create(name="A") - b = Site.objects.create(name="B") - - class Policy: - def has_perm(self, request, obj, config, selection): - # allow only selecting site with name "B" - if selection and getattr(selection, "name", None) == "B": - return True - return False - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - clean=None, - permission=Policy(), - permission_denied_message="Not allowed for this selection", - ) - } - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - # Attempt to set A (denied) – should add a field error via clean - form = form_cls({"name": service.name, "site_binding": a.pk}, instance=service) - self.assertFalse(form.is_valid()) - self.assertIn("Not allowed for this selection", form.errors.get("site_binding", [""])[0]) - self.assertIsNone(Site.objects.get(pk=a.pk).service) - - # Set B (allowed) - form2 = form_cls({"name": service.name, "site_binding": b.pk}, instance=service) - self.assertTrue(form2.is_valid()) - obj = form2.save() - self.assertEqual(Site.objects.get(pk=b.pk).service, obj) - - def test_global_reverse_permission_policy_callable(self): - """Test global reverse permission policy with callable.""" - service = Service.objects.create(name="svc") - a = Site.objects.create(name="A") - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - # Global policy: deny if selection name is "A" - reverse_permission_policy = staticmethod( - lambda request, obj, config, selection: ( - False - if (selection is not None and getattr(selection, "name", None) == "A") - else True - ) - ) - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - ) - } - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - form = form_cls({"name": service.name, "site_binding": a.pk}, instance=service) - self.assertFalse(form.is_valid()) - self.assertIn("permission", form.errors.get("site_binding", [""])[0].lower()) - - def test_policy_object_message_is_used_when_field_has_no_override(self): - """Test that policy object message is used when field has no override.""" - service = Service.objects.create(name="svc") - a = Site.objects.create(name="A") - - class DenyPolicy: - permission_denied_message = "Custom deny message from policy" - - def __call__(self, request, obj, config, selection): - return False - - class TempAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_permissions_enabled = True - reverse_permission_mode = "disable" - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - permission=DenyPolicy(), - ) - } - - request = self.factory.post("/") - admin_inst = TempAdmin(Service, DummySite()) - form_cls = admin_inst.get_form(request, service) - form = form_cls({"name": service.name, "site_binding": a.pk}, instance=service) - self.assertFalse(form.is_valid()) - self.assertIn( - "custom deny message from policy", - form.errors.get("site_binding", [""])[0].lower(), - ) - - def test_bulk_permission_error_during_operations(self): - """Test permission errors during bulk operations.""" - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, - ) - } - - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - - # Mock the queryset filter and update methods to simulate a permission error - with mock.patch.object(Extension._default_manager, 'filter') as mock_filter: - mock_queryset = mock.Mock() - mock_queryset.update.side_effect = PermissionDenied("Database permission denied") - mock_filter.return_value = mock_queryset - - # Should raise ValidationError with meaningful message - with self.assertRaises(forms.ValidationError) as cm: - form.save() - - error_message = str(cm.exception) - # The error message format depends on which bulk operation fails - self.assertTrue( - "Bulk operation failed" in error_message or - "Unexpected error during bulk" in error_message - ) - self.assertIn("Database permission denied", error_message) \ No newline at end of file diff --git a/tests/test_signals.py b/tests/test_signals.py deleted file mode 100644 index 81bf1aa..0000000 --- a/tests/test_signals.py +++ /dev/null @@ -1,365 +0,0 @@ -"""Test suite for signal bypassing behavior in bulk operations.""" - -# Django imports -from django.contrib import admin - -# Project imports -from django_admin_reversefields.mixins import ( - ReverseRelationAdminMixin, - ReverseRelationConfig, -) - -# Test imports -from .models import Extension, Service, Site -from .shared_test_base import BaseAdminMixinTestCase - - -class SignalBypassTests(BaseAdminMixinTestCase): - """Test suite for signal bypassing behavior in bulk operations.""" - - def test_bulk_operations_bypass_pre_save_signals(self): - """Test that bulk operations don't trigger pre_save signals.""" - # Create test data - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B") - - # Initially bind site_a to service - site_a.service = self.service - site_a.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, # Enable bulk operations - ) - } - - # Set up signal tracking - pre_save_calls = [] - - def track_pre_save(sender, instance, **kwargs): - pre_save_calls.append(instance) - - from django.db.models.signals import pre_save - pre_save.connect(track_pre_save, sender=Site) - - try: - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Change selection from site_a to site_b using bulk operations - form = form_cls( - { - "name": self.service.name, - "site_binding": site_b.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that pre_save signals were NOT triggered for bulk operations - # The signal should not be called because bulk operations use .update() - self.assertEqual(len(pre_save_calls), 0, - "pre_save signals should not be triggered during bulk operations") - - finally: - # Clean up signal connection - pre_save.disconnect(track_pre_save, sender=Site) - - def test_bulk_operations_bypass_post_save_signals(self): - """Test that bulk operations don't trigger post_save signals.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - ext_3 = Extension.objects.create(number="1003") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=True, # Enable bulk operations - ) - } - - # Set up signal tracking - post_save_calls = [] - - def track_post_save(sender, instance, created, **kwargs): - post_save_calls.append((instance, created)) - - from django.db.models.signals import post_save - post_save.connect(track_post_save, sender=Extension) - - try: - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Bind multiple extensions using bulk operations - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk, ext_3.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that post_save signals were NOT triggered for bulk operations - # The signal should not be called because bulk operations use .update() - self.assertEqual(len(post_save_calls), 0, - "post_save signals should not be triggered during bulk operations") - - finally: - # Clean up signal connection - post_save.disconnect(track_post_save, sender=Extension) - - def test_individual_operations_still_trigger_pre_save_signals(self): - """Test that individual operations still trigger pre_save signals when bulk=False.""" - # Create test data - site_a = Site.objects.create(name="Site A") - site_b = Site.objects.create(name="Site B") - - # Initially bind site_a to service - site_a.service = self.service - site_a.save() - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=False, # Use individual operations (existing behavior) - ) - } - - # Set up signal tracking - pre_save_calls = [] - - def track_pre_save(sender, instance, **kwargs): - pre_save_calls.append(instance) - - from django.db.models.signals import pre_save - pre_save.connect(track_pre_save, sender=Site) - - try: - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Change selection from site_a to site_b using individual operations - form = form_cls( - { - "name": self.service.name, - "site_binding": site_b.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that pre_save signals WERE triggered for individual operations - # Should have at least one call (for the bind operation) - self.assertGreater(len(pre_save_calls), 0, - "pre_save signals should be triggered during individual operations") - - # Verify the signal was called for the correct instances - signal_instances = [call for call in pre_save_calls] - self.assertTrue(any(isinstance(instance, Site) for instance in signal_instances), - "pre_save should be called for Site instances") - - finally: - # Clean up signal connection - pre_save.disconnect(track_pre_save, sender=Site) - - def test_individual_operations_still_trigger_post_save_signals(self): - """Test that individual operations still trigger post_save signals when bulk=False.""" - # Create test data - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=False, # Use individual operations (existing behavior) - ) - } - - # Set up signal tracking - post_save_calls = [] - - def track_post_save(sender, instance, created, **kwargs): - post_save_calls.append((instance, created)) - - from django.db.models.signals import post_save - post_save.connect(track_post_save, sender=Extension) - - try: - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Bind extensions using individual operations - form = form_cls( - { - "name": self.service.name, - "assigned_extensions": [ext_1.pk, ext_2.pk], - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that post_save signals WERE triggered for individual operations - # Should have at least one call (for the bind operations) - self.assertGreater(len(post_save_calls), 0, - "post_save signals should be triggered during individual operations") - - # Verify the signal was called for the correct instances - signal_instances = [call[0] for call in post_save_calls] - self.assertTrue(any(isinstance(instance, Extension) for instance in signal_instances), - "post_save should be called for Extension instances") - - finally: - # Clean up signal connection - post_save.disconnect(track_post_save, sender=Extension) - - def test_mixed_bulk_and_individual_signal_behavior(self): - """Test signal behavior when mixing bulk and individual configurations.""" - # Create test data - site_a = Site.objects.create(name="Site A") - ext_1 = Extension.objects.create(number="1001") - ext_2 = Extension.objects.create(number="1002") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, # Bulk operations - should bypass signals - ), - "assigned_extensions": ReverseRelationConfig( - model=Extension, - fk_field="service", - multiple=True, - bulk=False, # Individual operations - should trigger signals - ), - } - - # Set up signal tracking for both models - site_pre_save_calls = [] - extension_pre_save_calls = [] - - def track_site_pre_save(sender, instance, **kwargs): - site_pre_save_calls.append(instance) - - def track_extension_pre_save(sender, instance, **kwargs): - extension_pre_save_calls.append(instance) - - from django.db.models.signals import pre_save - pre_save.connect(track_site_pre_save, sender=Site) - pre_save.connect(track_extension_pre_save, sender=Extension) - - try: - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Update both fields simultaneously - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, # Bulk operation - "assigned_extensions": [ext_1.pk, ext_2.pk], # Individual operations - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that Site signals were NOT triggered (bulk=True) - self.assertEqual(len(site_pre_save_calls), 0, - "Site pre_save signals should not be triggered (bulk=True)") - - # Verify that Extension signals WERE triggered (bulk=False) - self.assertGreater(len(extension_pre_save_calls), 0, - "Extension pre_save signals should be triggered (bulk=False)") - - finally: - # Clean up signal connections - pre_save.disconnect(track_site_pre_save, sender=Site) - pre_save.disconnect(track_extension_pre_save, sender=Extension) - - def test_bulk_operations_with_custom_signals(self): - """Test that bulk operations bypass custom model signals as well.""" - # Create test data - site_a = Site.objects.create(name="Site A") - - class TestAdmin(ReverseRelationAdminMixin, admin.ModelAdmin): - reverse_relations = { - "site_binding": ReverseRelationConfig( - model=Site, - fk_field="service", - multiple=False, - bulk=True, # Enable bulk operations - ) - } - - # Set up custom signal tracking - custom_signal_calls = [] - - def track_custom_signal(sender, instance, **kwargs): - custom_signal_calls.append(instance) - - # Create a custom signal and connect it - from django.dispatch import Signal - custom_signal = Signal() - - # Mock the Site model's save method to emit custom signal - original_save = Site.save - - def custom_save(self, *args, **kwargs): - result = original_save(self, *args, **kwargs) - custom_signal.send(sender=Site, instance=self) - return result - - Site.save = custom_save - custom_signal.connect(track_custom_signal, sender=Site) - - try: - request = self.factory.post("/") - admin_inst = TestAdmin(Service, self.site) - form_cls = admin_inst.get_form(request, self.service) - - # Bind site using bulk operations - form = form_cls( - { - "name": self.service.name, - "site_binding": site_a.pk, - }, - instance=self.service, - ) - self.assertTrue(form.is_valid()) - form.save() - - # Verify that custom signals were NOT triggered (because save() wasn't called) - self.assertEqual(len(custom_signal_calls), 0, - "Custom signals should not be triggered during bulk operations") - - finally: - # Clean up - Site.save = original_save - custom_signal.disconnect(track_custom_signal, sender=Site) \ No newline at end of file