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/formsets/models.py b/src/polymorphic/formsets/models.py index a8b29f8c..98527e76 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 or () + # 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 @@ -75,16 +76,30 @@ 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 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) - if extra_exclude: - exclude += list(extra_exclude) + + # Determine if we should pass exclude to modelform_factory + # 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: + 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 +108,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) @@ -177,7 +197,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: @@ -198,10 +218,15 @@ 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() + 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: @@ -211,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) @@ -352,6 +389,7 @@ def polymorphic_modelformset_factory( FormSet = modelformset_factory(**kwargs) child_kwargs = { + "fields": fields, # 'exclude': exclude, } if child_form_kwargs: @@ -435,6 +473,7 @@ def polymorphic_inlineformset_factory( FormSet = inlineformset_factory(**kwargs) child_kwargs = { + "fields": fields, # 'exclude': exclude, } if child_form_kwargs: diff --git a/src/polymorphic/tests/deletion/migrations/0001_initial.py b/src/polymorphic/tests/deletion/migrations/0001_initial.py index af7e1d81..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 13:29 +# Generated by Django 4.2 on 2026-01-08 00:17 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 551ad213..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 13:29 +# Generated by Django 4.2 on 2026-01-08 00:17 from django.conf import settings from django.db import migrations, models @@ -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 105142ab..b23b8324 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, @@ -16,7 +15,12 @@ RelationBase, RelationA, RelationB, + SpecialBook, + Book, ) +from django.test import TestCase +from django.contrib.contenttypes.models import ContentType +from polymorphic.formsets import polymorphic_modelformset_factory, PolymorphicFormSetChild class RegressionTests(TestCase): @@ -321,3 +325,118 @@ def test_issue_252_abstract_base_class(self): self.assertIsInstance(relations[0], RelationBase) self.assertIsInstance(relations[1], RelationA) self.assertIsInstance(relations[2], RelationB) + + +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) + + 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) + + def test_formset_with_none_instance(self): + """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) + + SpecialBookFormSet = polymorphic_modelformset_factory( + Book, + fields="__all__", + formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),), + ) + + # 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 + 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) + 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) 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'" },