diff --git a/src/polymorphic/tests/admin.py b/src/polymorphic/tests/admin.py index 4460a37f..bac91bb1 100644 --- a/src/polymorphic/tests/admin.py +++ b/src/polymorphic/tests/admin.py @@ -21,6 +21,10 @@ InlineParent, NoChildren, ModelWithPolyFK, + M2MAdminTest, + M2MAdminTestChildA, + M2MAdminTestChildB, + M2MAdminTestChildC, ) @@ -79,3 +83,24 @@ class NoChildrenAdmin(PolymorphicParentModelAdmin): @register(ModelWithPolyFK) class ModelWithPolyFKAdmin(ModelAdmin): fields = ["name", "poly_fk"] + + +@register(M2MAdminTest) +class M2MAdminTestAdmin(PolymorphicParentModelAdmin): + list_filter = (PolymorphicChildModelFilter,) + child_models = (M2MAdminTestChildA, M2MAdminTestChildB, M2MAdminTestChildC) + + +@register(M2MAdminTestChildA) +class M2MAdminTestChildA(PolymorphicChildModelAdmin): + raw_id_fields = ("child_bs",) + + +@register(M2MAdminTestChildB) +class M2MAdminTestChildB(PolymorphicChildModelAdmin): + raw_id_fields = ("child_as",) + + +@register(M2MAdminTestChildC) +class M2MAdminTestChildC(PolymorphicChildModelAdmin): + raw_id_fields = ("child_as",) diff --git a/src/polymorphic/tests/deletion/migrations/0001_initial.py b/src/polymorphic/tests/deletion/migrations/0001_initial.py index cc8aaad4..4c098980 100644 --- a/src/polymorphic/tests/deletion/migrations/0001_initial.py +++ b/src/polymorphic/tests/deletion/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2 on 2026-01-06 23:40 +# Generated by Django 4.2 on 2026-01-07 11:43 from decimal import Decimal from django.conf import settings diff --git a/src/polymorphic/tests/migrations/0001_initial.py b/src/polymorphic/tests/migrations/0001_initial.py index 85ceafdd..e47b53a4 100644 --- a/src/polymorphic/tests/migrations/0001_initial.py +++ b/src/polymorphic/tests/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2 on 2026-01-06 23:40 +# Generated by Django 4.2 on 2026-01-07 11:43 from django.conf import settings from django.db import migrations, models @@ -193,6 +193,18 @@ class Migration(migrations.Migration): ('title', models.CharField(max_length=30)), ], ), + migrations.CreateModel( + name='M2MAdminTest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + ), migrations.CreateModel( name='Model2A', fields=[ @@ -579,6 +591,29 @@ class Migration(migrations.Migration): }, bases=('tests.inittestmodel',), ), + migrations.CreateModel( + name='M2MAdminTestChildA', + fields=[ + ('m2madmintest_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.m2madmintest')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('tests.m2madmintest',), + ), + migrations.CreateModel( + name='M2MAdminTestChildB', + fields=[ + ('m2madmintest_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.m2madmintest')), + ('child_as', models.ManyToManyField(blank=True, related_name='related_bs', to='tests.m2madmintestchilda')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('tests.m2madmintest',), + ), migrations.CreateModel( name='Middle', fields=[ @@ -1418,6 +1453,17 @@ class Migration(migrations.Migration): }, bases=('tests.disparatekeyschild2',), ), + migrations.CreateModel( + name='M2MAdminTestChildC', + fields=[ + ('m2madmintestchildb_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.m2madmintestchildb')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('tests.m2madmintestchildb',), + ), migrations.CreateModel( name='Model2C', fields=[ @@ -1561,6 +1607,11 @@ class Migration(migrations.Migration): ('user_profiles', models.ManyToManyField(related_name='user_teams', to='tests.userprofile')), ], ), + migrations.AddField( + model_name='m2madmintestchilda', + name='child_bs', + field=models.ManyToManyField(blank=True, related_name='related_as', to='tests.m2madmintestchildb'), + ), migrations.CreateModel( name='InlineModelB', fields=[ diff --git a/src/polymorphic/tests/models.py b/src/polymorphic/tests/models.py index d9ae53de..7ea8c1ec 100644 --- a/src/polymorphic/tests/models.py +++ b/src/polymorphic/tests/models.py @@ -888,3 +888,22 @@ class DisparateKeysGrandChild2(DisparateKeysChild2): class DisparateKeysGrandChild(DisparateKeysChild1): text_grand_child = models.CharField(max_length=30) + + +class M2MAdminTest(PolymorphicModel): + name = models.CharField(max_length=30) + + def __str__(self): + return self.name + + +class M2MAdminTestChildA(M2MAdminTest): + child_bs = models.ManyToManyField("M2MAdminTestChildB", related_name="related_as", blank=True) + + +class M2MAdminTestChildB(M2MAdminTest): + child_as = models.ManyToManyField("M2MAdminTestChildA", related_name="related_bs", blank=True) + + +class M2MAdminTestChildC(M2MAdminTestChildB): + pass diff --git a/src/polymorphic/tests/test_admin.py b/src/polymorphic/tests/test_admin.py index a406b193..0d6a76aa 100644 --- a/src/polymorphic/tests/test_admin.py +++ b/src/polymorphic/tests/test_admin.py @@ -898,3 +898,147 @@ def test_changelist_filter_persists_after_edit(self): ] # Only obj_b should be displayed (obj_c has a different content type) assert displayed_ids == [obj_b.pk] + + +class M2MAdminTests(_GenericAdminFormTest): + def test_m2m_admin_raw_id_fields(self): + """ + Test M2M relationships in polymorphic admin using raw_id_fields. + + This test verifies that: + 1. M2M relationships can be created between polymorphic child models + 2. Raw ID field lookups display the correct polymorphic instances + 3. M2M relationships are properly saved and displayed + """ + from polymorphic.tests.models import ( + M2MAdminTestChildA, + M2MAdminTestChildB, + M2MAdminTestChildC, + ) + + # Create test instances + a1 = M2MAdminTestChildA.objects.create(name="A1") + b1 = M2MAdminTestChildB.objects.create(name="B1") + c1 = M2MAdminTestChildC.objects.create(name="C1") + + # Navigate to A1's change page + self.page.goto(self.change_url(M2MAdminTestChildA, a1.pk)) + + # Verify the page loaded correctly + assert self.page.locator("input[name='name']").input_value() == "A1" + + # Test adding B1 to A1's child_bs field using the raw ID lookup + # Click the lookup button (magnifying glass icon) for child_bs + with self.page.expect_popup(timeout=10000) as popup_info: + self.page.click("a#lookup_id_child_bs") + + popup = popup_info.value + popup.wait_for_load_state("networkidle") + + # In the popup, we should see both B1 and C1 (since C1 is a subclass of B) + # Verify B1 is present in the list + b1_link = popup.locator("table#result_list a:has-text('B1')") + expect(b1_link).to_be_visible() + + # Verify C1 is present in the list + c1_link = popup.locator("table#result_list a:has-text('C1')") + expect(c1_link).to_be_visible() + + # Verify that A1 is not present + expect(popup.locator("table#result_list a:has-text('A1')")).to_have_count(0) + + # Click B1 to select it + with popup.expect_event("close", timeout=10000): + b1_link.click() + + # Wait a moment for the popup to close and value to be set + self.page.wait_for_timeout(500) + + # Verify B1's ID was added to the raw ID field + child_bs_value = self.page.locator("input[name='child_bs']").input_value() + assert str(b1.pk) in child_bs_value + + # Now add C1 as well by clicking the lookup again + with self.page.expect_popup(timeout=10000) as popup_info: + self.page.click("a#lookup_id_child_bs") + + popup = popup_info.value + popup.wait_for_load_state("networkidle") + + # Click C1 to add it + c1_link = popup.locator("table#result_list a:has-text('C1')") + with popup.expect_event("close", timeout=10000): + c1_link.click() + + self.page.wait_for_timeout(500) + + # Verify both B1 and C1 are in the raw ID field (comma-separated) + child_bs_value = self.page.locator("input[name='child_bs']").input_value() + assert str(b1.pk) in child_bs_value + assert str(c1.pk) in child_bs_value + + # Save the changes to A1 + with self.page.expect_navigation(timeout=10000) as nav_info: + self.page.click("input[name='_save']") + + response = nav_info.value + assert response.status < 400 + + # Verify the relationships were saved + a1.refresh_from_db() + child_bs_ids = set(a1.child_bs.values_list("pk", flat=True)) + assert b1.pk in child_bs_ids + assert c1.pk in child_bs_ids + assert len(child_bs_ids) == 2 + + # Now test the reverse relationship: add A1 to B1's child_as + self.page.goto(self.change_url(M2MAdminTestChildB, b1.pk)) + + # Verify the page loaded correctly + assert self.page.locator("input[name='name']").input_value() == "B1" + + # Click the lookup button for child_as + with self.page.expect_popup(timeout=10000) as popup_info: + self.page.click("a#lookup_id_child_as") + + popup = popup_info.value + popup.wait_for_load_state("networkidle") + + # In the popup, we should see A1 + a1_link = popup.locator("table#result_list a:has-text('A1')") + expect(a1_link).to_be_visible() + + # Verify that AB is not present + expect(popup.locator("table#result_list a:has-text('B1')")).to_have_count(0) + expect(popup.locator("table#result_list a:has-text('C1')")).to_have_count(0) + + # Click A1 to select it + with popup.expect_event("close", timeout=10000): + a1_link.click() + + self.page.wait_for_timeout(500) + + # Verify A1's ID was added to the raw ID field + child_as_value = self.page.locator("input[name='child_as']").input_value() + assert str(a1.pk) in child_as_value + + # Save the changes to B1 + with self.page.expect_navigation(timeout=10000) as nav_info: + self.page.click("input[name='_save']") + + response = nav_info.value + assert response.status < 400 + + # Verify the relationship was saved + b1.refresh_from_db() + child_as_ids = set(b1.child_as.values_list("pk", flat=True)) + assert a1.pk in child_as_ids + assert len(child_as_ids) == 1 + + # Verify the relationships display correctly when we go back to the change page + self.page.goto(self.change_url(M2MAdminTestChildA, a1.pk)) + + # The raw ID field should show both B1 and C1 + child_bs_value = self.page.locator("input[name='child_bs']").input_value() + assert str(b1.pk) in child_bs_value + assert str(c1.pk) in child_bs_value