diff --git a/docs/admin.rst b/docs/admin.rst index a9d98ecd..bc9701a0 100644 --- a/docs/admin.rst +++ b/docs/admin.rst @@ -113,6 +113,83 @@ Child model types can be filtered by adding a :attr:`~django.contrib.admin.ModelAdmin.list_filter` attribute. See the example above. +Using Raw ID Fields with Polymorphic Models +-------------------------------------------- + +When using :attr:`~django.contrib.admin.ModelAdmin.raw_id_fields` for foreign keys or many-to-many +fields that point to polymorphic models, the popup change list normally shows **all** child types. +This can be confusing when a field on a specific child model should only reference instances of +another specific child type. + +The :class:`~polymorphic.admin.PolymorphicForeignKeyRawIdWidget` automatically filters the popup +change list to show only instances of the correct child model type. + +Example +~~~~~~~ + +Consider polymorphic models where ``ChildA`` has a many-to-many field to ``ChildB``: + +.. code-block:: python + + from django.db import models + from polymorphic.models import PolymorphicModel + + class ParentModel(PolymorphicModel): + name = models.CharField(max_length=100) + + class ChildA(ParentModel): + related_children = models.ManyToManyField('ChildB', related_name='related_to_a') + + class ChildB(ParentModel): + description = models.TextField() + +To use the filtered raw ID widget in the admin: + +.. code-block:: python + + from django.contrib import admin + from polymorphic.admin import ( + PolymorphicParentModelAdmin, + PolymorphicChildModelAdmin, + PolymorphicForeignKeyRawIdWidget, + ) + from .models import ParentModel, ChildA, ChildB + + @admin.register(ChildA) + class ChildAAdmin(PolymorphicChildModelAdmin): + base_model = ParentModel + raw_id_fields = ['related_children'] + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name in self.raw_id_fields: + kwargs['widget'] = PolymorphicForeignKeyRawIdWidget( + db_field.remote_field, self.admin_site + ) + return super().formfield_for_manytomany(db_field, request, **kwargs) + + @admin.register(ChildB) + class ChildBAdmin(PolymorphicChildModelAdmin): + base_model = ParentModel + + @admin.register(ParentModel) + class ParentModelAdmin(PolymorphicParentModelAdmin): + base_model = ParentModel + child_models = (ChildA, ChildB) + +Now when editing a ``ChildA`` instance, clicking the magnifying glass icon next to the +``related_children`` field will open a popup showing only ``ChildB`` instances, not all child types. + +How it works +~~~~~~~~~~~~ + +The :class:`~polymorphic.admin.PolymorphicForeignKeyRawIdWidget` adds a ``polymorphic_ctype`` +parameter to the popup URL, which is automatically detected by +:class:`~polymorphic.admin.PolymorphicParentModelAdmin` to filter the queryset by content type. + +This feature is backward compatible - existing raw ID fields without the custom widget will continue +to show all child types as before. + + Inline models ------------- diff --git a/src/polymorphic/admin/__init__.py b/src/polymorphic/admin/__init__.py index 48933f1f..cd69f398 100644 --- a/src/polymorphic/admin/__init__.py +++ b/src/polymorphic/admin/__init__.py @@ -12,6 +12,7 @@ # Utils from .forms import PolymorphicModelChoiceForm +from .widgets import PolymorphicForeignKeyRawIdWidget # Expose generic admin features too. There is no need to split those # as the admin already relies on contenttypes. @@ -31,6 +32,7 @@ "PolymorphicChildModelAdmin", "PolymorphicModelChoiceForm", "PolymorphicChildModelFilter", + "PolymorphicForeignKeyRawIdWidget", "PolymorphicInlineAdminForm", "PolymorphicInlineAdminFormSet", "PolymorphicInlineSupportMixin", diff --git a/src/polymorphic/admin/parentadmin.py b/src/polymorphic/admin/parentadmin.py index 5866ad84..12947296 100644 --- a/src/polymorphic/admin/parentadmin.py +++ b/src/polymorphic/admin/parentadmin.py @@ -187,6 +187,16 @@ def get_queryset(self, request): qs = super().get_queryset(request) if not self.polymorphic_list: qs = qs.non_polymorphic() + + # Filter by content type if specified (for raw ID widget popups) + ctype_id = request.GET.get("polymorphic_ctype") + if ctype_id: + try: + qs = qs.filter(polymorphic_ctype_id=int(ctype_id)) + except (ValueError, TypeError): + # Invalid ctype_id, ignore the filter + pass + return qs def add_view(self, request, form_url="", extra_context=None): diff --git a/src/polymorphic/admin/widgets.py b/src/polymorphic/admin/widgets.py new file mode 100644 index 00000000..4a53ca6f --- /dev/null +++ b/src/polymorphic/admin/widgets.py @@ -0,0 +1,72 @@ +""" +Widgets for polymorphic admin. +""" + +from django.contrib.admin.widgets import ForeignKeyRawIdWidget +from django.contrib.contenttypes.models import ContentType + + +class PolymorphicForeignKeyRawIdWidget(ForeignKeyRawIdWidget): + """ + A raw ID widget that automatically filters the popup change list by content type. + + When used with polymorphic models, this widget adds a 'polymorphic_ctype' parameter + to the popup URL, which filters the queryset to show only instances of the specific + child model type. + + Example usage in a child admin:: + + from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicForeignKeyRawIdWidget + + class ChildAAdmin(PolymorphicChildModelAdmin): + base_model = ParentModel + raw_id_fields = ['related_child_b'] + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name in self.raw_id_fields: + kwargs['widget'] = PolymorphicForeignKeyRawIdWidget( + db_field.remote_field, self.admin_site + ) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + """ + + def url_parameters(self): + """ + Add polymorphic_ctype parameter to the popup URL if the related model is polymorphic. + """ + # Handle None rel case before calling super + if not self.rel: + return {} + + params = super().url_parameters() + + # Get the content type for the related model + ctype_id = self._get_polymorphic_ctype() + if ctype_id: + params["polymorphic_ctype"] = ctype_id + + return params + + def _get_polymorphic_ctype(self): + """ + Get the content type ID for the related model if it's polymorphic. + + Returns: + int or None: The content type ID if the model is polymorphic, None otherwise. + """ + if not self.rel: + return None + + related_model = self.rel.model + + # Check if the model has polymorphic_ctype field (indicating it's polymorphic) + if not hasattr(related_model, "polymorphic_ctype"): + return None + + # Get the content type for this specific model + try: + ctype = ContentType.objects.get_for_model(related_model, for_concrete_model=False) + return ctype.id + except Exception: + # If anything goes wrong, just don't add the parameter + return None diff --git a/src/polymorphic/tests/test_raw_id_widget.py b/src/polymorphic/tests/test_raw_id_widget.py new file mode 100644 index 00000000..1e800cd1 --- /dev/null +++ b/src/polymorphic/tests/test_raw_id_widget.py @@ -0,0 +1,225 @@ +""" +Tests for PolymorphicForeignKeyRawIdWidget and raw ID field filtering. +""" + +from django.contrib.admin.sites import AdminSite +from django.contrib.contenttypes.models import ContentType +from django.test import RequestFactory, TestCase + +from polymorphic.admin import PolymorphicForeignKeyRawIdWidget, PolymorphicParentModelAdmin +from polymorphic.tests.models import Model2A, Model2B, Model2C, Model2D, RelationBase + + +class RawIdWidgetTests(TestCase): + """Test the PolymorphicForeignKeyRawIdWidget functionality.""" + + def setUp(self): + self.factory = RequestFactory() + self.site = AdminSite() + + def test_widget_adds_polymorphic_ctype_parameter(self): + """Test that the widget adds polymorphic_ctype to URL parameters.""" + # Use a real model field's remote_field + from polymorphic.tests.models import RelationBase + + # Get the FK field's remote_field which points to RelationBase (polymorphic) + fk_field = RelationBase._meta.get_field("fk") + + widget = PolymorphicForeignKeyRawIdWidget(fk_field.remote_field, self.site) + params = widget.url_parameters() + + # Should include the polymorphic_ctype parameter + self.assertIn("polymorphic_ctype", params) + + # Should be the content type ID for RelationBase + expected_ct = ContentType.objects.get_for_model(RelationBase, for_concrete_model=False) + self.assertEqual(params["polymorphic_ctype"], expected_ct.id) + + def test_widget_with_non_polymorphic_model(self): + """Test that the widget works with non-polymorphic models.""" + from polymorphic.tests.models import ( + PlainParentModelWithManager, + PlainChildModelWithManager, + ) + + # Get a FK field that points to a non-polymorphic model + fk_field = PlainChildModelWithManager._meta.get_field("fk") + + widget = PolymorphicForeignKeyRawIdWidget(fk_field.remote_field, self.site) + params = widget.url_parameters() + + # Should not include polymorphic_ctype for non-polymorphic models + self.assertNotIn("polymorphic_ctype", params) + + def test_widget_with_different_child_models(self): + """Test widget with different polymorphic child models.""" + from polymorphic.tests.models import RelationA, RelationB, RelationBC + + for model_class in [RelationA, RelationB, RelationBC]: + # Get the FK field from the model + fk_field = model_class._meta.get_field("fk") + + widget = PolymorphicForeignKeyRawIdWidget(fk_field.remote_field, self.site) + params = widget.url_parameters() + + # All these models have FK to RelationBase (the parent) + expected_ct = ContentType.objects.get_for_model(RelationBase, for_concrete_model=False) + self.assertEqual(params["polymorphic_ctype"], expected_ct.id) + + def test_widget_with_no_rel(self): + """Test widget handles missing rel gracefully.""" + widget = PolymorphicForeignKeyRawIdWidget(None, self.site) + params = widget.url_parameters() + + # Should not crash, just return empty params + self.assertNotIn("polymorphic_ctype", params) + + +class ParentAdminQuerysetFilteringTests(TestCase): + """Test the queryset filtering in PolymorphicParentModelAdmin.""" + + def setUp(self): + self.factory = RequestFactory() + self.site = AdminSite() + + # Create test data + self.obj_a = Model2A.objects.create(field1="A1") + self.obj_b = Model2B.objects.create(field1="B1", field2="B2") + self.obj_c = Model2C.objects.create(field1="C1", field2="C2", field3="C3") + self.obj_d = Model2D.objects.create(field1="D1", field2="D2", field3="D3", field4="D4") + + # Create admin instance + class TestParentAdmin(PolymorphicParentModelAdmin): + base_model = Model2A + child_models = [Model2A, Model2B, Model2C, Model2D] + + self.admin = TestParentAdmin(Model2A, self.site) + + def test_queryset_without_ctype_filter(self): + """Test that queryset returns all objects when no filter is applied.""" + request = self.factory.get("/admin/tests/model2a/") + qs = self.admin.get_queryset(request) + + # Should return all objects (non-polymorphic by default) + self.assertEqual(qs.count(), 4) + + def test_queryset_with_valid_ctype_filter(self): + """Test queryset filtering with valid polymorphic_ctype parameter.""" + ct_b = ContentType.objects.get_for_model(Model2B, for_concrete_model=False) + request = self.factory.get(f"/admin/tests/model2a/?polymorphic_ctype={ct_b.id}") + qs = self.admin.get_queryset(request) + + # Should only return Model2B instances + self.assertEqual(qs.count(), 1) + obj = qs.first() + self.assertEqual(obj.pk, self.obj_b.pk) + + def test_queryset_with_child_ctype_filter(self): + """Test filtering by a deeper child model.""" + ct_d = ContentType.objects.get_for_model(Model2D, for_concrete_model=False) + request = self.factory.get(f"/admin/tests/model2a/?polymorphic_ctype={ct_d.id}") + qs = self.admin.get_queryset(request) + + # Should only return Model2D instances + self.assertEqual(qs.count(), 1) + obj = qs.first() + self.assertEqual(obj.pk, self.obj_d.pk) + + def test_queryset_with_invalid_ctype_filter(self): + """Test that invalid ctype parameter is ignored gracefully.""" + request = self.factory.get("/admin/tests/model2a/?polymorphic_ctype=invalid") + qs = self.admin.get_queryset(request) + + # Should return all objects (filter ignored) + self.assertEqual(qs.count(), 4) + + def test_queryset_with_non_integer_ctype(self): + """Test that non-integer ctype parameter is ignored.""" + request = self.factory.get("/admin/tests/model2a/?polymorphic_ctype=abc") + qs = self.admin.get_queryset(request) + + # Should return all objects (filter ignored) + self.assertEqual(qs.count(), 4) + + def test_queryset_with_nonexistent_ctype_id(self): + """Test with a content type ID that doesn't match any objects.""" + # Use a very high ID that shouldn't exist + request = self.factory.get("/admin/tests/model2a/?polymorphic_ctype=99999") + qs = self.admin.get_queryset(request) + + # Should return no objects + self.assertEqual(qs.count(), 0) + + def test_queryset_filtering_preserves_other_filters(self): + """Test that polymorphic_ctype filter works with other query parameters.""" + ct_c = ContentType.objects.get_for_model(Model2C, for_concrete_model=False) + request = self.factory.get(f"/admin/tests/model2a/?polymorphic_ctype={ct_c.id}&field1=C1") + qs = self.admin.get_queryset(request) + + # The polymorphic_ctype filter should be applied + # (other filters are handled by changelist, not get_queryset) + self.assertEqual(qs.count(), 1) + + +class IntegrationTests(TestCase): + """Integration tests for the complete raw ID widget workflow.""" + + def setUp(self): + self.factory = RequestFactory() + self.site = AdminSite() + + # Create test data using RelationBase models + from polymorphic.tests.models import RelationBase, RelationB + + self.obj_b1 = RelationB.objects.create(field_base="B1", field_b="B2-1") + self.obj_b2 = RelationB.objects.create(field_base="B2", field_b="B2-2") + self.obj_base = RelationBase.objects.create(field_base="Base1") + + def test_widget_url_filters_admin_popup(self): + """Test that widget-generated URL properly filters the admin popup.""" + from polymorphic.tests.models import RelationB, RelationBase + + # Use a real FK field that points to RelationBase + fk_field = RelationB._meta.get_field("fk") + + widget = PolymorphicForeignKeyRawIdWidget(fk_field.remote_field, self.site) + params = widget.url_parameters() + + # Create admin and request with those parameters + class TestParentAdmin(PolymorphicParentModelAdmin): + base_model = RelationBase + child_models = [RelationBase, RelationB] + + admin = TestParentAdmin(RelationBase, self.site) + + # Build URL with widget parameters + query_string = "&".join(f"{k}={v}" for k, v in params.items()) + request = self.factory.get(f"/admin/tests/relationbase/?{query_string}") + + qs = admin.get_queryset(request) + + # The widget adds the content type for RelationBase (the model the FK points to) + # When filtering by RelationBase content type, we only get instances with that exact type + # RelationB instances have their own content type, so they won't be included + self.assertEqual(qs.count(), 1) + pks = list(qs.values_list("pk", flat=True)) + self.assertIn(self.obj_base.pk, pks) + # RelationB instances are NOT included because they have RelationB content type + self.assertNotIn(self.obj_b1.pk, pks) + self.assertNotIn(self.obj_b2.pk, pks) + + def test_backward_compatibility(self): + """Test that existing behavior without the widget still works.""" + from polymorphic.tests.models import RelationBase, RelationB + + class TestParentAdmin(PolymorphicParentModelAdmin): + base_model = RelationBase + child_models = [RelationBase, RelationB] + + admin = TestParentAdmin(RelationBase, self.site) + request = self.factory.get("/admin/tests/relationbase/") + + qs = admin.get_queryset(request) + + # Should return all objects as before + self.assertEqual(qs.count(), 3)