From e780a94e4cf52363e0638ad4c940c86aa20ebabf Mon Sep 17 00:00:00 2001 From: Johanan Oppong Amoateng Date: Tue, 23 Dec 2025 04:12:45 +0000 Subject: [PATCH 1/9] fix PolymorphicFormSetChild overrides form exclude --- src/polymorphic/formsets/models.py | 28 ++++++++++++++---- src/polymorphic/tests/test_regression.py | 37 ++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/polymorphic/formsets/models.py b/src/polymorphic/formsets/models.py index a8b29f8c..ff834940 100644 --- a/src/polymorphic/formsets/models.py +++ b/src/polymorphic/formsets/models.py @@ -48,7 +48,7 @@ def __init__( # This is mostly needed for the generic inline formsets self._form_base = form self.fields = fields - self.exclude = exclude or () + self.exclude = exclude self.formfield_callback = formfield_callback self.widgets = widgets self.localized_fields = localized_fields @@ -75,16 +75,29 @@ def get_form(self, **kwargs): # that doesn't completely replace all 'exclude' settings defined per child type, # we allow to define things like 'extra_...' fields that are amended to the current child settings. - exclude = list(self.exclude) + # Handle exclude parameter carefully: + # - If exclude was explicitly provided (not None), use it + # - If extra_exclude is provided, merge it with self.exclude + # - If neither was provided, don't pass exclude to modelform_factory at all, + # allowing the form's Meta.exclude to take effect extra_exclude = kwargs.pop("extra_exclude", None) - if extra_exclude: - exclude += list(extra_exclude) + + # Determine if we should pass exclude to modelform_factory + should_pass_exclude = self.exclude is not None or extra_exclude is not None + + if should_pass_exclude: + if self.exclude is not None: + exclude = list(self.exclude) + else: + exclude = [] + + if extra_exclude: + exclude += list(extra_exclude) defaults = { "form": self._form_base, "formfield_callback": self.formfield_callback, "fields": self.fields, - "exclude": exclude, # 'for_concrete_model': for_concrete_model, "localized_fields": self.localized_fields, "labels": self.labels, @@ -93,6 +106,11 @@ def get_form(self, **kwargs): "widgets": self.widgets, # 'field_classes': field_classes, } + + # Only add exclude to defaults if we determined it should be passed + if should_pass_exclude: + defaults["exclude"] = exclude + defaults.update(kwargs) return modelform_factory(self.model, **defaults) diff --git a/src/polymorphic/tests/test_regression.py b/src/polymorphic/tests/test_regression.py index 105142ab..e1ce078d 100644 --- a/src/polymorphic/tests/test_regression.py +++ b/src/polymorphic/tests/test_regression.py @@ -1,8 +1,7 @@ -from django.test import TestCase - +from django import forms from django.db import models from django.db.models import functions -from polymorphic.models import PolymorphicTypeInvalid +from polymorphic.models import PolymorphicModel, PolymorphicTypeInvalid from polymorphic.tests.models import ( Bottom, Middle, @@ -17,6 +16,9 @@ RelationA, RelationB, ) +from django.test import TestCase + +from polymorphic.formsets import polymorphic_modelformset_factory, PolymorphicFormSetChild class RegressionTests(TestCase): @@ -321,3 +323,32 @@ def test_issue_252_abstract_base_class(self): self.assertIsInstance(relations[0], RelationBase) self.assertIsInstance(relations[1], RelationA) self.assertIsInstance(relations[2], RelationB) + + +class Author(models.Model): + pass + + +class Book(PolymorphicModel): + author = models.ForeignKey(Author, on_delete=models.CASCADE) + + +class SpecialBook(Book): + pass + + +class SpecialBookForm(forms.ModelForm): + class Meta: + model = SpecialBook + exclude = ("author",) + + +class TestFormsetExclude(TestCase): + def test_formset_child_respects_exclude(self): + SpecialBookFormSet = polymorphic_modelformset_factory( + Book, + fields=[], + formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),), + ) + formset = SpecialBookFormSet(queryset=SpecialBook.objects.none()) + self.assertNotIn("author", formset.forms[0].fields) From e4df89d5189e4ce8483260e90b3f39eb8946c0d2 Mon Sep 17 00:00:00 2001 From: Johanan Oppong Amoateng Date: Tue, 23 Dec 2025 04:52:52 +0000 Subject: [PATCH 2/9] fix Issue with polymorphic_ctype when populating polymorphic inline formsets --- src/polymorphic/formsets/models.py | 10 ++++++++ src/polymorphic/tests/test_regression.py | 29 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/polymorphic/formsets/models.py b/src/polymorphic/formsets/models.py index ff834940..827ed6d1 100644 --- a/src/polymorphic/formsets/models.py +++ b/src/polymorphic/formsets/models.py @@ -185,6 +185,16 @@ def _construct_form(self, i, **kwargs): defaults["initial"] = self.initial[i] except IndexError: pass + # Normalize polymorphic_ctype in initial data if it's a ContentType instance + # This allows users to set initial[i]['polymorphic_ctype'] = ct (ContentType instance) + # while the form field expects an integer ID + if "initial" in defaults and "polymorphic_ctype" in defaults["initial"]: + ct_value = defaults["initial"]["polymorphic_ctype"] + if isinstance(ct_value, ContentType): + # Create a copy to avoid modifying the original formset.initial + defaults["initial"] = defaults["initial"].copy() + # Convert ContentType instance to its ID + defaults["initial"]["polymorphic_ctype"] = ct_value.pk # Allow extra forms to be empty, unless they're part of # the minimum forms. if i >= self.initial_form_count() and i >= self.min_num: diff --git a/src/polymorphic/tests/test_regression.py b/src/polymorphic/tests/test_regression.py index e1ce078d..4b5e352a 100644 --- a/src/polymorphic/tests/test_regression.py +++ b/src/polymorphic/tests/test_regression.py @@ -352,3 +352,32 @@ def test_formset_child_respects_exclude(self): ) formset = SpecialBookFormSet(queryset=SpecialBook.objects.none()) self.assertNotIn("author", formset.forms[0].fields) + + def test_formset_initial_with_contenttype_instance(self): + """Test that polymorphic_ctype can be set as ContentType instance in initial data (issue #549)""" + from django.contrib.contenttypes.models import ContentType + + ct = ContentType.objects.get_for_model(SpecialBook, for_concrete_model=False) + + SpecialBookFormSet = polymorphic_modelformset_factory( + Book, + fields="__all__", + formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),), + ) + + # Set initial data with ContentType instance (as users do in issue #549) + formset = SpecialBookFormSet( + queryset=SpecialBook.objects.none(), + initial=[{"polymorphic_ctype": ct}], + ) + + # Should not raise an error when creating the formset + form = formset.forms[0] + + # Verify the polymorphic_ctype field is properly set up with the ID + self.assertIn("polymorphic_ctype", form.fields) + + # The critical assertion: the field's initial value should be the ID (int), + # not the ContentType instance. This proves the normalization worked. + self.assertEqual(form.fields["polymorphic_ctype"].initial, ct.pk) + self.assertIsInstance(form.fields["polymorphic_ctype"].initial, int) From f4d0bd9f33015a268e92cda68e841ed1a4ec2dfd Mon Sep 17 00:00:00 2001 From: Johanan Oppong Amoateng Date: Tue, 23 Dec 2025 05:51:09 +0000 Subject: [PATCH 3/9] fix Nested polymorphic_inline_formsets gives AttributeError: 'NoneType' object has no attribute 'get_real_instance_class' --- src/polymorphic/formsets/models.py | 4 ++-- src/polymorphic/tests/test_regression.py | 29 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/polymorphic/formsets/models.py b/src/polymorphic/formsets/models.py index 827ed6d1..e6e2697f 100644 --- a/src/polymorphic/formsets/models.py +++ b/src/polymorphic/formsets/models.py @@ -205,7 +205,7 @@ def _construct_form(self, i, **kwargs): # Need to find the model that will be displayed in this form. # Hence, peeking in the self.queryset_data beforehand. if self.is_bound: - if "instance" in defaults: + if "instance" in defaults and defaults["instance"] is not None: # Object is already bound to a model, won't change the content type model = defaults["instance"].get_real_instance_class() # allow proxy models else: @@ -226,7 +226,7 @@ def _construct_form(self, i, **kwargs): f"Child model type {model} is not part of the formset" ) else: - if "instance" in defaults: + if "instance" in defaults and defaults["instance"] is not None: model = defaults["instance"].get_real_instance_class() # allow proxy models elif "polymorphic_ctype" in defaults.get("initial", {}): model = defaults["initial"]["polymorphic_ctype"].model_class() diff --git a/src/polymorphic/tests/test_regression.py b/src/polymorphic/tests/test_regression.py index 4b5e352a..bf4e2bcb 100644 --- a/src/polymorphic/tests/test_regression.py +++ b/src/polymorphic/tests/test_regression.py @@ -324,7 +324,6 @@ def test_issue_252_abstract_base_class(self): self.assertIsInstance(relations[1], RelationA) self.assertIsInstance(relations[2], RelationB) - class Author(models.Model): pass @@ -381,3 +380,31 @@ def test_formset_initial_with_contenttype_instance(self): # not the ContentType instance. This proves the normalization worked. self.assertEqual(form.fields["polymorphic_ctype"].initial, ct.pk) self.assertIsInstance(form.fields["polymorphic_ctype"].initial, int) + + def test_formset_with_none_instance(self): + """Test that formset handles None instance without AttributeError (issue #363).""" + from django.contrib.contenttypes.models import ContentType + + ct = ContentType.objects.get_for_model(SpecialBook, for_concrete_model=False) + + SpecialBookFormSet = polymorphic_modelformset_factory( + Book, + fields="__all__", + formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),), + ) + + # Simulate nested formset scenario where instance can be None + # This happens when creating a new form in a nested formset + formset = SpecialBookFormSet( + queryset=SpecialBook.objects.none(), + ) + + # Access the form - this should not raise AttributeError + # even though the instance might be None + try: + form = formset.forms[0] + # Verify the form was created successfully + self.assertIsNotNone(form) + self.assertIn("polymorphic_ctype", form.fields) + except AttributeError as e: + self.fail(f"Formset with None instance raised AttributeError: {e}") From 0f02290463f5a0f1538d1cfd52e434e1affe76f1 Mon Sep 17 00:00:00 2001 From: Johanan Oppong Amoateng Date: Tue, 23 Dec 2025 10:25:35 +0000 Subject: [PATCH 4/9] fix coverage --- src/polymorphic/tests/test_regression.py | 30 +++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/polymorphic/tests/test_regression.py b/src/polymorphic/tests/test_regression.py index bf4e2bcb..d04ee148 100644 --- a/src/polymorphic/tests/test_regression.py +++ b/src/polymorphic/tests/test_regression.py @@ -17,7 +17,7 @@ RelationB, ) from django.test import TestCase - +from django.contrib.contenttypes.models import ContentType from polymorphic.formsets import polymorphic_modelformset_factory, PolymorphicFormSetChild @@ -408,3 +408,31 @@ def test_formset_with_none_instance(self): self.assertIn("polymorphic_ctype", form.fields) except AttributeError as e: self.fail(f"Formset with None instance raised AttributeError: {e}") + + def test_combined_formset_behaviors(self): + # 1. __init__ exclude handling + child_none = PolymorphicFormSetChild(Book, form=SpecialBookForm, exclude=None) + self.assertEqual(child_none.exclude, ()) + + child_list = PolymorphicFormSetChild(Book, form=SpecialBookForm, exclude=["author"]) + self.assertIn("author", child_list.exclude) + + # 2. get_form exclude merging + form = child_list.get_form(extra_exclude=["field1"]) + self.assertIn("author", form._meta.exclude) + self.assertIn("field1", form._meta.exclude) + + form_meta_default = child_none.get_form() + self.assertIn("author", form_meta_default._meta.exclude) + + # 3. polymorphic_ctype normalization + ct = ContentType.objects.get_for_model(SpecialBook, for_concrete_model=False) + child_ct = PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm) + form_ct = child_ct._construct_form(0, initial={"polymorphic_ctype": ct}) + self.assertIsInstance(form_ct.initial["polymorphic_ctype"], int) + self.assertEqual(form_ct.initial["polymorphic_ctype"], ct.pk) + + # 4. _construct_form with None instance + form_none = child_ct._construct_form(0, instance=None) + self.assertIsNotNone(form_none) + self.assertIn("polymorphic_ctype", form_none.fields) From 39276ea806522ee69497aa50b874cfc8acdbe553 Mon Sep 17 00:00:00 2001 From: Johanan Oppong Amoateng Date: Tue, 23 Dec 2025 10:56:43 +0000 Subject: [PATCH 5/9] fix coverage --- src/polymorphic/formsets/models.py | 39 +++++++++++++++--------- src/polymorphic/tests/test_regression.py | 18 ++++++----- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/polymorphic/formsets/models.py b/src/polymorphic/formsets/models.py index e6e2697f..6351823a 100644 --- a/src/polymorphic/formsets/models.py +++ b/src/polymorphic/formsets/models.py @@ -48,7 +48,8 @@ def __init__( # This is mostly needed for the generic inline formsets self._form_base = form self.fields = fields - self.exclude = exclude + # Normalize exclude=None to () to match Django's formset behavior + self.exclude = () if exclude is None else exclude self.formfield_callback = formfield_callback self.widgets = widgets self.localized_fields = localized_fields @@ -76,17 +77,18 @@ def get_form(self, **kwargs): # we allow to define things like 'extra_...' fields that are amended to the current child settings. # Handle exclude parameter carefully: - # - If exclude was explicitly provided (not None), use it + # - If exclude was explicitly provided (not empty), use it # - If extra_exclude is provided, merge it with self.exclude # - If neither was provided, don't pass exclude to modelform_factory at all, # allowing the form's Meta.exclude to take effect extra_exclude = kwargs.pop("extra_exclude", None) # Determine if we should pass exclude to modelform_factory - should_pass_exclude = self.exclude is not None or extra_exclude is not None + # Treat empty tuples/lists the same as None to allow form's Meta.exclude to take effect + should_pass_exclude = bool(self.exclude) or extra_exclude is not None if should_pass_exclude: - if self.exclude is not None: + if self.exclude: exclude = list(self.exclude) else: exclude = [] @@ -185,16 +187,6 @@ def _construct_form(self, i, **kwargs): defaults["initial"] = self.initial[i] except IndexError: pass - # Normalize polymorphic_ctype in initial data if it's a ContentType instance - # This allows users to set initial[i]['polymorphic_ctype'] = ct (ContentType instance) - # while the form field expects an integer ID - if "initial" in defaults and "polymorphic_ctype" in defaults["initial"]: - ct_value = defaults["initial"]["polymorphic_ctype"] - if isinstance(ct_value, ContentType): - # Create a copy to avoid modifying the original formset.initial - defaults["initial"] = defaults["initial"].copy() - # Convert ContentType instance to its ID - defaults["initial"]["polymorphic_ctype"] = ct_value.pk # Allow extra forms to be empty, unless they're part of # the minimum forms. if i >= self.initial_form_count() and i >= self.min_num: @@ -229,7 +221,12 @@ def _construct_form(self, i, **kwargs): if "instance" in defaults and defaults["instance"] is not None: model = defaults["instance"].get_real_instance_class() # allow proxy models elif "polymorphic_ctype" in defaults.get("initial", {}): - model = defaults["initial"]["polymorphic_ctype"].model_class() + ct_value = defaults["initial"]["polymorphic_ctype"] + # Handle both ContentType instances and IDs + if isinstance(ct_value, ContentType): + model = ct_value.model_class() + else: + model = ContentType.objects.get_for_id(ct_value).model_class() elif i < len(self.queryset_data): model = self.queryset_data[i].__class__ else: @@ -239,6 +236,18 @@ def _construct_form(self, i, **kwargs): child_models = list(self.child_forms.keys()) model = child_models[(i - total_known) % len(child_models)] + # Normalize polymorphic_ctype in initial data if it's a ContentType instance + # This allows users to set initial[i]['polymorphic_ctype'] = ct (ContentType instance) + # while the form field expects an integer ID + # We do this AFTER determining the model so the model determination can use the ContentType + if "initial" in defaults and "polymorphic_ctype" in defaults["initial"]: + ct_value = defaults["initial"]["polymorphic_ctype"] + if isinstance(ct_value, ContentType): + # Create a copy to avoid modifying the original formset.initial + defaults["initial"] = defaults["initial"].copy() + # Convert ContentType instance to its ID + defaults["initial"]["polymorphic_ctype"] = ct_value.pk + form_class = self.get_form_class(model) form = form_class(**defaults) self.add_fields(form, i) diff --git a/src/polymorphic/tests/test_regression.py b/src/polymorphic/tests/test_regression.py index d04ee148..a2366cbd 100644 --- a/src/polymorphic/tests/test_regression.py +++ b/src/polymorphic/tests/test_regression.py @@ -427,12 +427,16 @@ def test_combined_formset_behaviors(self): # 3. polymorphic_ctype normalization ct = ContentType.objects.get_for_model(SpecialBook, for_concrete_model=False) - child_ct = PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm) - form_ct = child_ct._construct_form(0, initial={"polymorphic_ctype": ct}) + SpecialBookFormSet = polymorphic_modelformset_factory( + Book, + fields="__all__", + formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),), + ) + formset = SpecialBookFormSet( + queryset=SpecialBook.objects.none(), + initial=[{"polymorphic_ctype": ct}], + ) + # The formset should normalize the ContentType instance to its ID + form_ct = formset.forms[0] self.assertIsInstance(form_ct.initial["polymorphic_ctype"], int) self.assertEqual(form_ct.initial["polymorphic_ctype"], ct.pk) - - # 4. _construct_form with None instance - form_none = child_ct._construct_form(0, instance=None) - self.assertIsNotNone(form_none) - self.assertIn("polymorphic_ctype", form_none.fields) From 8f0e8ca671396d7bae3a484ffbb0a54496dfe73f Mon Sep 17 00:00:00 2001 From: Johanan Oppong Amoateng Date: Sat, 3 Jan 2026 00:20:50 +0000 Subject: [PATCH 6/9] fix tests --- src/polymorphic/formsets/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/polymorphic/formsets/models.py b/src/polymorphic/formsets/models.py index 6351823a..98527e76 100644 --- a/src/polymorphic/formsets/models.py +++ b/src/polymorphic/formsets/models.py @@ -389,6 +389,7 @@ def polymorphic_modelformset_factory( FormSet = modelformset_factory(**kwargs) child_kwargs = { + "fields": fields, # 'exclude': exclude, } if child_form_kwargs: @@ -472,6 +473,7 @@ def polymorphic_inlineformset_factory( FormSet = inlineformset_factory(**kwargs) child_kwargs = { + "fields": fields, # 'exclude': exclude, } if child_form_kwargs: From 39aade5d4409cc429fa0847ced8d222efc86e8f0 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 6 Jan 2026 13:18:57 -0800 Subject: [PATCH 7/9] run ruff, remake migrations --- src/polymorphic/tests/deletion/migrations/0001_initial.py | 4 ++-- src/polymorphic/tests/migrations/0001_initial.py | 4 ++-- src/polymorphic/tests/test_regression.py | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/polymorphic/tests/deletion/migrations/0001_initial.py b/src/polymorphic/tests/deletion/migrations/0001_initial.py index af7e1d81..fec039ca 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-07 13:29 +# Generated by Django 4.2 on 2026-01-07 14:26 from decimal import Decimal from django.conf import settings @@ -12,8 +12,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ diff --git a/src/polymorphic/tests/migrations/0001_initial.py b/src/polymorphic/tests/migrations/0001_initial.py index 551ad213..b3e9507f 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-07 13:29 +# Generated by Django 4.2 on 2026-01-07 14:26 from django.conf import settings from django.db import migrations, models @@ -14,8 +14,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), ('contenttypes', '0002_remove_content_type_name'), + ('auth', '0012_alter_user_first_name_max_length'), ] operations = [ diff --git a/src/polymorphic/tests/test_regression.py b/src/polymorphic/tests/test_regression.py index a2366cbd..b76040d1 100644 --- a/src/polymorphic/tests/test_regression.py +++ b/src/polymorphic/tests/test_regression.py @@ -324,6 +324,7 @@ def test_issue_252_abstract_base_class(self): self.assertIsInstance(relations[1], RelationA) self.assertIsInstance(relations[2], RelationB) + class Author(models.Model): pass From 7135d0c3325de45441ff0c8cdb969e482c96f300 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 7 Jan 2026 20:53:04 -0800 Subject: [PATCH 8/9] fix test for fix for issue #363 --- src/polymorphic/tests/test_regression.py | 41 +++++++++++++++--------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/polymorphic/tests/test_regression.py b/src/polymorphic/tests/test_regression.py index b76040d1..b74443a4 100644 --- a/src/polymorphic/tests/test_regression.py +++ b/src/polymorphic/tests/test_regression.py @@ -383,7 +383,12 @@ def test_formset_initial_with_contenttype_instance(self): self.assertIsInstance(form.fields["polymorphic_ctype"].initial, int) def test_formset_with_none_instance(self): - """Test that formset handles None instance without AttributeError (issue #363).""" + """Test that formset handles None instance without AttributeError (issue #363). + + This occurs when a bound formset has a pk that doesn't exist in the queryset, + causing Django's _existing_object to return None. The polymorphic formset + must handle this gracefully instead of calling get_real_instance_class() on None. + """ from django.contrib.contenttypes.models import ContentType ct = ContentType.objects.get_for_model(SpecialBook, for_concrete_model=False) @@ -394,21 +399,25 @@ def test_formset_with_none_instance(self): formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),), ) - # Simulate nested formset scenario where instance can be None - # This happens when creating a new form in a nested formset - formset = SpecialBookFormSet( - queryset=SpecialBook.objects.none(), - ) - - # Access the form - this should not raise AttributeError - # even though the instance might be None - try: - form = formset.forms[0] - # Verify the form was created successfully - self.assertIsNotNone(form) - self.assertIn("polymorphic_ctype", form.fields) - except AttributeError as e: - self.fail(f"Formset with None instance raised AttributeError: {e}") + # Simulate the scenario where _existing_object returns None: + # - Bound formset with data + # - Claims to have an initial form (INITIAL_FORMS > 0) + # - But the pk doesn't exist in queryset, so _existing_object returns None + data = { + "form-TOTAL_FORMS": "1", + "form-INITIAL_FORMS": "1", + "form-MIN_NUM_FORMS": "0", + "form-MAX_NUM_FORMS": "1000", + "form-0-id": "99999", # Non-existent pk - _existing_object will return None + "form-0-polymorphic_ctype": str(ct.pk), + } + + formset = SpecialBookFormSet(data=data, queryset=SpecialBook.objects.none()) + + # This should not raise AttributeError when instance is None + forms = formset.forms + self.assertEqual(len(forms), 1) + self.assertIn("polymorphic_ctype", forms[0].fields) def test_combined_formset_behaviors(self): # 1. __init__ exclude handling From 5b03a94c44e4716af27e4412a2ab0b8b84a9b1d3 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 7 Jan 2026 22:26:38 -0800 Subject: [PATCH 9/9] move test models into models, bump version and update change log --- docs/changelog.rst | 7 ++++ pyproject.toml | 2 +- src/polymorphic/__init__.py | 2 +- .../tests/deletion/migrations/0001_initial.py | 4 +-- .../tests/migrations/0001_initial.py | 33 +++++++++++++++++-- src/polymorphic/tests/models.py | 12 +++++++ src/polymorphic/tests/test_regression.py | 14 ++------ uv.lock | 2 +- 8 files changed, 57 insertions(+), 19 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index de6700b6..a0a4f1d3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,13 @@ Changelog ========= +v4.8.0 (2026-01-08) +------------------- + +* Fixed `PolymorphicFormSetChild overrides form exclude `_ +* Fixed `Issue with polymorphic_ctype when populating polymorphic inline formsets. `_ +* Fixed `Nested polymorphic_inline_formsets gives AttributeError: 'NoneType' object has no attribute 'get_real_instance_class' `_ + v4.7.0 (2026-01-07) ------------------- diff --git a/pyproject.toml b/pyproject.toml index 02a60a19..2644a2ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "django-polymorphic" -version = "4.7.0" +version = "4.8.0" description = "Seamless polymorphic inheritance for Django models." readme = "README.md" license = "BSD-3-Clause" diff --git a/src/polymorphic/__init__.py b/src/polymorphic/__init__.py index 6cd77210..be126577 100644 --- a/src/polymorphic/__init__.py +++ b/src/polymorphic/__init__.py @@ -19,7 +19,7 @@ Seamless Polymorphic Inheritance for Django Models """ -VERSION = "4.7.0" +VERSION = "4.8.0" __title__ = "Django Polymorphic" __version__ = VERSION # version synonym for backwards compatibility diff --git a/src/polymorphic/tests/deletion/migrations/0001_initial.py b/src/polymorphic/tests/deletion/migrations/0001_initial.py index fec039ca..b1698f1b 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-07 14:26 +# Generated by Django 4.2 on 2026-01-08 00:17 from decimal import Decimal from django.conf import settings @@ -12,8 +12,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), ] operations = [ diff --git a/src/polymorphic/tests/migrations/0001_initial.py b/src/polymorphic/tests/migrations/0001_initial.py index b3e9507f..1321f060 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-07 14:26 +# Generated by Django 4.2 on 2026-01-08 00:17 from django.conf import settings from django.db import migrations, models @@ -14,8 +14,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), ] operations = [ @@ -30,6 +30,12 @@ class Migration(migrations.Migration): 'base_manager_name': 'objects', }, ), + migrations.CreateModel( + name='Author', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), migrations.CreateModel( name='Base', fields=[ @@ -62,6 +68,18 @@ class Migration(migrations.Migration): }, bases=(polymorphic.showfields.ShowFieldTypeAndContent, models.Model), ), + migrations.CreateModel( + name='Book', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.author')), + ('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='Bookmark', fields=[ @@ -986,6 +1004,17 @@ class Migration(migrations.Migration): }, bases=('tests.account',), ), + migrations.CreateModel( + name='SpecialBook', + fields=[ + ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.book')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('tests.book',), + ), migrations.CreateModel( name='SubclassSelectorAbstractConcreteModel', fields=[ diff --git a/src/polymorphic/tests/models.py b/src/polymorphic/tests/models.py index 9f527550..747c7359 100644 --- a/src/polymorphic/tests/models.py +++ b/src/polymorphic/tests/models.py @@ -988,3 +988,15 @@ class DirectM2MContainer(models.Model): def __str__(self): return self.name + + +class Author(models.Model): + pass + + +class Book(PolymorphicModel): + author = models.ForeignKey(Author, on_delete=models.CASCADE) + + +class SpecialBook(Book): + pass diff --git a/src/polymorphic/tests/test_regression.py b/src/polymorphic/tests/test_regression.py index b74443a4..b23b8324 100644 --- a/src/polymorphic/tests/test_regression.py +++ b/src/polymorphic/tests/test_regression.py @@ -15,6 +15,8 @@ RelationBase, RelationA, RelationB, + SpecialBook, + Book, ) from django.test import TestCase from django.contrib.contenttypes.models import ContentType @@ -325,18 +327,6 @@ def test_issue_252_abstract_base_class(self): self.assertIsInstance(relations[2], RelationB) -class Author(models.Model): - pass - - -class Book(PolymorphicModel): - author = models.ForeignKey(Author, on_delete=models.CASCADE) - - -class SpecialBook(Book): - pass - - class SpecialBookForm(forms.ModelForm): class Meta: model = SpecialBook diff --git a/uv.lock b/uv.lock index c5bf7466..7db1edc8 100644 --- a/uv.lock +++ b/uv.lock @@ -627,7 +627,7 @@ wheels = [ [[package]] name = "django-polymorphic" -version = "4.7.0" +version = "4.8.0" source = { editable = "." } dependencies = [ { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },