Skip to content

Commit 5b76a07

Browse files
authored
Merge pull request #756 from JohananOppongAmoateng/578
FormSet Bugs
2 parents 4810fe2 + 5b03a94 commit 5b76a07

9 files changed

Lines changed: 222 additions & 16 deletions

File tree

docs/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
v4.8.0 (2026-01-08)
5+
-------------------
6+
7+
* Fixed `PolymorphicFormSetChild overrides form exclude <https://github.com/jazzband/django-polymorphic/issues/578>`_
8+
* Fixed `Issue with polymorphic_ctype when populating polymorphic inline formsets. <https://github.com/jazzband/django-polymorphic/issues/549>`_
9+
* Fixed `Nested polymorphic_inline_formsets gives AttributeError: 'NoneType' object has no attribute 'get_real_instance_class' <https://github.com/jazzband/django-polymorphic/issues/363>`_
10+
411
v4.7.0 (2026-01-07)
512
-------------------
613

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "django-polymorphic"
7-
version = "4.7.0"
7+
version = "4.8.0"
88
description = "Seamless polymorphic inheritance for Django models."
99
readme = "README.md"
1010
license = "BSD-3-Clause"

src/polymorphic/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
Seamless Polymorphic Inheritance for Django Models
2020
"""
2121

22-
VERSION = "4.7.0"
22+
VERSION = "4.8.0"
2323

2424
__title__ = "Django Polymorphic"
2525
__version__ = VERSION # version synonym for backwards compatibility

src/polymorphic/formsets/models.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ def __init__(
4848
# This is mostly needed for the generic inline formsets
4949
self._form_base = form
5050
self.fields = fields
51-
self.exclude = exclude or ()
51+
# Normalize exclude=None to () to match Django's formset behavior
52+
self.exclude = () if exclude is None else exclude
5253
self.formfield_callback = formfield_callback
5354
self.widgets = widgets
5455
self.localized_fields = localized_fields
@@ -75,16 +76,30 @@ def get_form(self, **kwargs):
7576
# that doesn't completely replace all 'exclude' settings defined per child type,
7677
# we allow to define things like 'extra_...' fields that are amended to the current child settings.
7778

78-
exclude = list(self.exclude)
79+
# Handle exclude parameter carefully:
80+
# - If exclude was explicitly provided (not empty), use it
81+
# - If extra_exclude is provided, merge it with self.exclude
82+
# - If neither was provided, don't pass exclude to modelform_factory at all,
83+
# allowing the form's Meta.exclude to take effect
7984
extra_exclude = kwargs.pop("extra_exclude", None)
80-
if extra_exclude:
81-
exclude += list(extra_exclude)
85+
86+
# Determine if we should pass exclude to modelform_factory
87+
# Treat empty tuples/lists the same as None to allow form's Meta.exclude to take effect
88+
should_pass_exclude = bool(self.exclude) or extra_exclude is not None
89+
90+
if should_pass_exclude:
91+
if self.exclude:
92+
exclude = list(self.exclude)
93+
else:
94+
exclude = []
95+
96+
if extra_exclude:
97+
exclude += list(extra_exclude)
8298

8399
defaults = {
84100
"form": self._form_base,
85101
"formfield_callback": self.formfield_callback,
86102
"fields": self.fields,
87-
"exclude": exclude,
88103
# 'for_concrete_model': for_concrete_model,
89104
"localized_fields": self.localized_fields,
90105
"labels": self.labels,
@@ -93,6 +108,11 @@ def get_form(self, **kwargs):
93108
"widgets": self.widgets,
94109
# 'field_classes': field_classes,
95110
}
111+
112+
# Only add exclude to defaults if we determined it should be passed
113+
if should_pass_exclude:
114+
defaults["exclude"] = exclude
115+
96116
defaults.update(kwargs)
97117

98118
return modelform_factory(self.model, **defaults)
@@ -177,7 +197,7 @@ def _construct_form(self, i, **kwargs):
177197
# Need to find the model that will be displayed in this form.
178198
# Hence, peeking in the self.queryset_data beforehand.
179199
if self.is_bound:
180-
if "instance" in defaults:
200+
if "instance" in defaults and defaults["instance"] is not None:
181201
# Object is already bound to a model, won't change the content type
182202
model = defaults["instance"].get_real_instance_class() # allow proxy models
183203
else:
@@ -198,10 +218,15 @@ def _construct_form(self, i, **kwargs):
198218
f"Child model type {model} is not part of the formset"
199219
)
200220
else:
201-
if "instance" in defaults:
221+
if "instance" in defaults and defaults["instance"] is not None:
202222
model = defaults["instance"].get_real_instance_class() # allow proxy models
203223
elif "polymorphic_ctype" in defaults.get("initial", {}):
204-
model = defaults["initial"]["polymorphic_ctype"].model_class()
224+
ct_value = defaults["initial"]["polymorphic_ctype"]
225+
# Handle both ContentType instances and IDs
226+
if isinstance(ct_value, ContentType):
227+
model = ct_value.model_class()
228+
else:
229+
model = ContentType.objects.get_for_id(ct_value).model_class()
205230
elif i < len(self.queryset_data):
206231
model = self.queryset_data[i].__class__
207232
else:
@@ -211,6 +236,18 @@ def _construct_form(self, i, **kwargs):
211236
child_models = list(self.child_forms.keys())
212237
model = child_models[(i - total_known) % len(child_models)]
213238

239+
# Normalize polymorphic_ctype in initial data if it's a ContentType instance
240+
# This allows users to set initial[i]['polymorphic_ctype'] = ct (ContentType instance)
241+
# while the form field expects an integer ID
242+
# We do this AFTER determining the model so the model determination can use the ContentType
243+
if "initial" in defaults and "polymorphic_ctype" in defaults["initial"]:
244+
ct_value = defaults["initial"]["polymorphic_ctype"]
245+
if isinstance(ct_value, ContentType):
246+
# Create a copy to avoid modifying the original formset.initial
247+
defaults["initial"] = defaults["initial"].copy()
248+
# Convert ContentType instance to its ID
249+
defaults["initial"]["polymorphic_ctype"] = ct_value.pk
250+
214251
form_class = self.get_form_class(model)
215252
form = form_class(**defaults)
216253
self.add_fields(form, i)
@@ -352,6 +389,7 @@ def polymorphic_modelformset_factory(
352389
FormSet = modelformset_factory(**kwargs)
353390

354391
child_kwargs = {
392+
"fields": fields,
355393
# 'exclude': exclude,
356394
}
357395
if child_form_kwargs:
@@ -435,6 +473,7 @@ def polymorphic_inlineformset_factory(
435473
FormSet = inlineformset_factory(**kwargs)
436474

437475
child_kwargs = {
476+
"fields": fields,
438477
# 'exclude': exclude,
439478
}
440479
if child_form_kwargs:

src/polymorphic/tests/deletion/migrations/0001_initial.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 4.2 on 2026-01-07 13:29
1+
# Generated by Django 4.2 on 2026-01-08 00:17
22

33
from decimal import Decimal
44
from django.conf import settings

src/polymorphic/tests/migrations/0001_initial.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 4.2 on 2026-01-07 13:29
1+
# Generated by Django 4.2 on 2026-01-08 00:17
22

33
from django.conf import settings
44
from django.db import migrations, models
@@ -30,6 +30,12 @@ class Migration(migrations.Migration):
3030
'base_manager_name': 'objects',
3131
},
3232
),
33+
migrations.CreateModel(
34+
name='Author',
35+
fields=[
36+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
37+
],
38+
),
3339
migrations.CreateModel(
3440
name='Base',
3541
fields=[
@@ -62,6 +68,18 @@ class Migration(migrations.Migration):
6268
},
6369
bases=(polymorphic.showfields.ShowFieldTypeAndContent, models.Model),
6470
),
71+
migrations.CreateModel(
72+
name='Book',
73+
fields=[
74+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
75+
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.author')),
76+
('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')),
77+
],
78+
options={
79+
'abstract': False,
80+
'base_manager_name': 'objects',
81+
},
82+
),
6583
migrations.CreateModel(
6684
name='Bookmark',
6785
fields=[
@@ -986,6 +1004,17 @@ class Migration(migrations.Migration):
9861004
},
9871005
bases=('tests.account',),
9881006
),
1007+
migrations.CreateModel(
1008+
name='SpecialBook',
1009+
fields=[
1010+
('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')),
1011+
],
1012+
options={
1013+
'abstract': False,
1014+
'base_manager_name': 'objects',
1015+
},
1016+
bases=('tests.book',),
1017+
),
9891018
migrations.CreateModel(
9901019
name='SubclassSelectorAbstractConcreteModel',
9911020
fields=[

src/polymorphic/tests/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,3 +988,15 @@ class DirectM2MContainer(models.Model):
988988

989989
def __str__(self):
990990
return self.name
991+
992+
993+
class Author(models.Model):
994+
pass
995+
996+
997+
class Book(PolymorphicModel):
998+
author = models.ForeignKey(Author, on_delete=models.CASCADE)
999+
1000+
1001+
class SpecialBook(Book):
1002+
pass

src/polymorphic/tests/test_regression.py

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
from django.test import TestCase
2-
1+
from django import forms
32
from django.db import models
43
from django.db.models import functions
5-
from polymorphic.models import PolymorphicTypeInvalid
4+
from polymorphic.models import PolymorphicModel, PolymorphicTypeInvalid
65
from polymorphic.tests.models import (
76
Bottom,
87
Middle,
@@ -16,7 +15,12 @@
1615
RelationBase,
1716
RelationA,
1817
RelationB,
18+
SpecialBook,
19+
Book,
1920
)
21+
from django.test import TestCase
22+
from django.contrib.contenttypes.models import ContentType
23+
from polymorphic.formsets import polymorphic_modelformset_factory, PolymorphicFormSetChild
2024

2125

2226
class RegressionTests(TestCase):
@@ -321,3 +325,118 @@ def test_issue_252_abstract_base_class(self):
321325
self.assertIsInstance(relations[0], RelationBase)
322326
self.assertIsInstance(relations[1], RelationA)
323327
self.assertIsInstance(relations[2], RelationB)
328+
329+
330+
class SpecialBookForm(forms.ModelForm):
331+
class Meta:
332+
model = SpecialBook
333+
exclude = ("author",)
334+
335+
336+
class TestFormsetExclude(TestCase):
337+
def test_formset_child_respects_exclude(self):
338+
SpecialBookFormSet = polymorphic_modelformset_factory(
339+
Book,
340+
fields=[],
341+
formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),),
342+
)
343+
formset = SpecialBookFormSet(queryset=SpecialBook.objects.none())
344+
self.assertNotIn("author", formset.forms[0].fields)
345+
346+
def test_formset_initial_with_contenttype_instance(self):
347+
"""Test that polymorphic_ctype can be set as ContentType instance in initial data (issue #549)"""
348+
from django.contrib.contenttypes.models import ContentType
349+
350+
ct = ContentType.objects.get_for_model(SpecialBook, for_concrete_model=False)
351+
352+
SpecialBookFormSet = polymorphic_modelformset_factory(
353+
Book,
354+
fields="__all__",
355+
formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),),
356+
)
357+
358+
# Set initial data with ContentType instance (as users do in issue #549)
359+
formset = SpecialBookFormSet(
360+
queryset=SpecialBook.objects.none(),
361+
initial=[{"polymorphic_ctype": ct}],
362+
)
363+
364+
# Should not raise an error when creating the formset
365+
form = formset.forms[0]
366+
367+
# Verify the polymorphic_ctype field is properly set up with the ID
368+
self.assertIn("polymorphic_ctype", form.fields)
369+
370+
# The critical assertion: the field's initial value should be the ID (int),
371+
# not the ContentType instance. This proves the normalization worked.
372+
self.assertEqual(form.fields["polymorphic_ctype"].initial, ct.pk)
373+
self.assertIsInstance(form.fields["polymorphic_ctype"].initial, int)
374+
375+
def test_formset_with_none_instance(self):
376+
"""Test that formset handles None instance without AttributeError (issue #363).
377+
378+
This occurs when a bound formset has a pk that doesn't exist in the queryset,
379+
causing Django's _existing_object to return None. The polymorphic formset
380+
must handle this gracefully instead of calling get_real_instance_class() on None.
381+
"""
382+
from django.contrib.contenttypes.models import ContentType
383+
384+
ct = ContentType.objects.get_for_model(SpecialBook, for_concrete_model=False)
385+
386+
SpecialBookFormSet = polymorphic_modelformset_factory(
387+
Book,
388+
fields="__all__",
389+
formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),),
390+
)
391+
392+
# Simulate the scenario where _existing_object returns None:
393+
# - Bound formset with data
394+
# - Claims to have an initial form (INITIAL_FORMS > 0)
395+
# - But the pk doesn't exist in queryset, so _existing_object returns None
396+
data = {
397+
"form-TOTAL_FORMS": "1",
398+
"form-INITIAL_FORMS": "1",
399+
"form-MIN_NUM_FORMS": "0",
400+
"form-MAX_NUM_FORMS": "1000",
401+
"form-0-id": "99999", # Non-existent pk - _existing_object will return None
402+
"form-0-polymorphic_ctype": str(ct.pk),
403+
}
404+
405+
formset = SpecialBookFormSet(data=data, queryset=SpecialBook.objects.none())
406+
407+
# This should not raise AttributeError when instance is None
408+
forms = formset.forms
409+
self.assertEqual(len(forms), 1)
410+
self.assertIn("polymorphic_ctype", forms[0].fields)
411+
412+
def test_combined_formset_behaviors(self):
413+
# 1. __init__ exclude handling
414+
child_none = PolymorphicFormSetChild(Book, form=SpecialBookForm, exclude=None)
415+
self.assertEqual(child_none.exclude, ())
416+
417+
child_list = PolymorphicFormSetChild(Book, form=SpecialBookForm, exclude=["author"])
418+
self.assertIn("author", child_list.exclude)
419+
420+
# 2. get_form exclude merging
421+
form = child_list.get_form(extra_exclude=["field1"])
422+
self.assertIn("author", form._meta.exclude)
423+
self.assertIn("field1", form._meta.exclude)
424+
425+
form_meta_default = child_none.get_form()
426+
self.assertIn("author", form_meta_default._meta.exclude)
427+
428+
# 3. polymorphic_ctype normalization
429+
ct = ContentType.objects.get_for_model(SpecialBook, for_concrete_model=False)
430+
SpecialBookFormSet = polymorphic_modelformset_factory(
431+
Book,
432+
fields="__all__",
433+
formset_children=(PolymorphicFormSetChild(SpecialBook, form=SpecialBookForm),),
434+
)
435+
formset = SpecialBookFormSet(
436+
queryset=SpecialBook.objects.none(),
437+
initial=[{"polymorphic_ctype": ct}],
438+
)
439+
# The formset should normalize the ContentType instance to its ID
440+
form_ct = formset.forms[0]
441+
self.assertIsInstance(form_ct.initial["polymorphic_ctype"], int)
442+
self.assertEqual(form_ct.initial["polymorphic_ctype"], ct.pk)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)