Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Changelog
=========

v4.8.0 (2026-01-08)
-------------------

* Fixed `PolymorphicFormSetChild overrides form exclude <https://github.com/jazzband/django-polymorphic/issues/578>`_
* Fixed `Issue with polymorphic_ctype when populating polymorphic inline formsets. <https://github.com/jazzband/django-polymorphic/issues/549>`_
* Fixed `Nested polymorphic_inline_formsets gives AttributeError: 'NoneType' object has no attribute 'get_real_instance_class' <https://github.com/jazzband/django-polymorphic/issues/363>`_

v4.7.0 (2026-01-07)
-------------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/polymorphic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 47 additions & 8 deletions src/polymorphic/formsets/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -352,6 +389,7 @@ def polymorphic_modelformset_factory(
FormSet = modelformset_factory(**kwargs)

child_kwargs = {
"fields": fields,
# 'exclude': exclude,
}
if child_form_kwargs:
Expand Down Expand Up @@ -435,6 +473,7 @@ def polymorphic_inlineformset_factory(
FormSet = inlineformset_factory(**kwargs)

child_kwargs = {
"fields": fields,
# 'exclude': exclude,
}
if child_form_kwargs:
Expand Down
2 changes: 1 addition & 1 deletion src/polymorphic/tests/deletion/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 30 additions & 1 deletion src/polymorphic/tests/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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=[
Expand Down Expand Up @@ -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=[
Expand Down Expand Up @@ -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=[
Expand Down
12 changes: 12 additions & 0 deletions src/polymorphic/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
125 changes: 122 additions & 3 deletions src/polymorphic/tests/test_regression.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading