Skip to content

Commit 90d3dcd

Browse files
committed
bugfix: polymorphic inlines broken if nested in non-polymorphic inlines
Fixes #183 Polymorphic admin functionality was broken if a polymorphic inline was nested within a non-polymorphic model. The existing tests only checked cases where all inlines were polymorphic.
1 parent 1fad074 commit 90d3dcd

File tree

7 files changed

+298
-8
lines changed

7 files changed

+298
-8
lines changed

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+
**3.3.3 (unreleased)**
5+
6+
* Fixed: polymorphic inlines don't work if nested inside non-polymorphic
7+
inlines. Fixes `#183`_.
8+
9+
.. _#183: https://github.com/theatlantic/django-nested-admin/issues/183
10+
411
**3.3.2 (Jun 11, 2020)**
512

613
* Fixed: Resolved sporadic MediaOrderConflictWarning issues on Django 2.2

nested_admin/nested.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,6 @@ def _create_formsets(self, request, obj, change):
313313
formsets = []
314314
inline_instances = []
315315
prefixes = {}
316-
has_polymorphic = False
317316

318317
for orig_formset, orig_inline in zip(orig_formsets, orig_inline_instances):
319318
if not hasattr(orig_formset, 'nesting_depth'):
@@ -324,7 +323,6 @@ def _create_formsets(self, request, obj, change):
324323

325324
nested_formsets_and_inline_instances = []
326325
if hasattr(orig_inline, 'child_inline_instances'):
327-
has_polymorphic = True
328326
for child_inline in orig_inline.child_inline_instances:
329327
nested_formsets_and_inline_instances += [
330328
(orig_formset, inline)
@@ -359,7 +357,9 @@ def _create_formsets(self, request, obj, change):
359357
if prefixes[prefix] != 1:
360358
prefix = "%s-%s" % (prefix, prefixes[prefix])
361359

362-
if has_polymorphic and form_obj:
360+
# Check if we're dealing with a polymorphic instance, and if
361+
# so, skip inlines for other child models
362+
if hasattr(form_obj, 'get_real_instance'):
363363
if hasattr(InlineFormSet, 'fk'):
364364
rel_model = InlineFormSet.fk.remote_field.model
365365
if not isinstance(form_obj, rel_model):

nested_admin/polymorphic.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import json
33

44
from django.conf import settings
5-
from django.contrib.admin import ModelAdmin
65
from django.utils.encoding import force_text
76
from polymorphic.formsets import (
87
BasePolymorphicInlineFormSet, BaseGenericPolymorphicInlineFormSet)
@@ -13,7 +12,7 @@
1312

1413
from .formsets import NestedInlineFormSetMixin, NestedBaseGenericInlineFormSetMixin
1514
from .nested import (
16-
NestedModelAdminMixin,
15+
NestedModelAdmin,
1716
NestedInlineModelAdminMixin, NestedGenericInlineModelAdminMixin,
1817
NestedInlineAdminFormsetMixin, NestedInlineAdminFormset)
1918

@@ -174,14 +173,16 @@ class NestedGenericStackedPolymorphicInline(NestedGenericPolymorphicInlineModelA
174173
template = 'nesting/admin/inlines/polymorphic_stacked.html'
175174

176175

176+
# django-polymorphic expects the parent admin to extend PolymorphicInlineSupportMixin,
177+
# but we don't need the downcast method of that mixin, so we skip it by calling
178+
# its super
177179
class NestedPolymorphicInlineSupportMixin(
178-
NestedPolymorphicAdminFormsetHelperMixin, PolymorphicInlineSupportMixin,
179-
NestedModelAdminMixin):
180+
NestedPolymorphicAdminFormsetHelperMixin, PolymorphicInlineSupportMixin):
180181

181182
def get_inline_formsets(self, request, formsets, inline_instances, obj=None, *args, **kwargs):
182183
return super(PolymorphicInlineSupportMixin, self).get_inline_formsets(
183184
request, formsets, inline_instances, obj, *args, **kwargs)
184185

185186

186-
class NestedPolymorphicModelAdmin(NestedPolymorphicInlineSupportMixin, ModelAdmin):
187+
class NestedPolymorphicModelAdmin(NestedPolymorphicInlineSupportMixin, NestedModelAdmin):
187188
pass

nested_admin/tests/nested_polymorphic/test_polymorphic_mixed_nesting/__init__.py

Whitespace-only changes.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import nested_admin
2+
from django.contrib import admin
3+
4+
from .models import (
5+
Block, BlockMarkdown, BlockRadioGroup, BlockRadioButton,
6+
Questionnaire, SurveyStep)
7+
8+
9+
class BlockRadioButtonInline(nested_admin.SortableHiddenMixin, nested_admin.NestedTabularInline):
10+
model = BlockRadioButton
11+
inline_classes = ("collapse", "open", "grp-collapse", "grp-open",)
12+
extra = 0
13+
14+
15+
class BlockInline(nested_admin.SortableHiddenMixin, nested_admin.NestedStackedPolymorphicInline):
16+
model = Block
17+
inline_classes = ("collapse", "open", "grp-collapse", "grp-open",)
18+
extra = 0
19+
sortable_field_name = "position"
20+
21+
class BlockMarkdownInline(nested_admin.NestedStackedPolymorphicInline.Child):
22+
model = BlockMarkdown
23+
24+
class BlockRadioGroupInline(nested_admin.NestedStackedPolymorphicInline.Child):
25+
model = BlockRadioGroup
26+
inlines = [BlockRadioButtonInline]
27+
28+
child_inlines = (
29+
BlockMarkdownInline,
30+
BlockRadioGroupInline,
31+
)
32+
33+
34+
class SurveyStepInline(nested_admin.NestedStackedInline):
35+
model = SurveyStep
36+
inlines = [BlockInline]
37+
inline_classes = ("collapse", "open", "grp-collapse", "grp-open",)
38+
extra = 0
39+
sortable_field_name = "position"
40+
41+
42+
@admin.register(Questionnaire)
43+
class QuestionnaireAdmin(nested_admin.NestedPolymorphicModelAdmin):
44+
inlines = [SurveyStepInline]
45+
46+
47+
@admin.register(SurveyStep)
48+
class SurveyStepAdmin(nested_admin.NestedPolymorphicModelAdmin):
49+
inlines = [BlockInline]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import django
2+
from django.db import models
3+
4+
from nested_admin.tests.compat import python_2_unicode_compatible
5+
6+
try:
7+
from polymorphic.models import PolymorphicModel
8+
except:
9+
# Temporary until django-polymorphic supports django 3.1
10+
if django.VERSION < (3, 1):
11+
raise
12+
else:
13+
PolymorphicModel = models.Model
14+
15+
16+
class SurveyStep(models.Model):
17+
position = models.PositiveIntegerField()
18+
survey = models.ForeignKey("Questionnaire", models.CASCADE)
19+
title = models.CharField(max_length=255)
20+
21+
class Meta:
22+
ordering = ['position']
23+
24+
def serialize(self):
25+
blocks = [b.serialize() for b in self.block_set.all()]
26+
return {
27+
"title": self.title,
28+
"blocks": blocks,
29+
}
30+
31+
32+
class Block(PolymorphicModel):
33+
position = models.PositiveIntegerField()
34+
survey_step = models.ForeignKey("SurveyStep", models.CASCADE)
35+
36+
class Meta:
37+
ordering = ['position']
38+
39+
40+
class BlockMarkdown(Block):
41+
value = models.TextField()
42+
43+
def serialize(self):
44+
return {
45+
"type": "markdown",
46+
"value": self.value,
47+
}
48+
49+
50+
class BlockRadioGroup(Block):
51+
label = models.CharField(max_length=255)
52+
53+
def serialize(self):
54+
buttons = [b.serialize() for b in self.blockradiobutton_set.all()]
55+
return {
56+
"type": "radiogroup",
57+
"label": self.label,
58+
"buttons": buttons,
59+
}
60+
61+
62+
class BlockRadioButton(models.Model):
63+
radio_group = models.ForeignKey(Block, models.CASCADE)
64+
label = models.CharField(max_length=255)
65+
position = models.PositiveIntegerField()
66+
67+
class Meta:
68+
ordering = ['position']
69+
70+
def serialize(self):
71+
return self.label
72+
73+
74+
@python_2_unicode_compatible
75+
class Questionnaire(models.Model):
76+
title = models.CharField(max_length=255)
77+
78+
def __str__(self):
79+
return self.title
80+
81+
def serialize(self):
82+
steps = [s.serialize() for s in self.surveystep_set.all()]
83+
return {
84+
"title": self.title,
85+
"steps": steps,
86+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from unittest import SkipTest
2+
from django.test import TestCase
3+
4+
from .models import (
5+
Block, BlockMarkdown, BlockRadioGroup, BlockRadioButton,
6+
Questionnaire, SurveyStep)
7+
8+
9+
try:
10+
from nested_admin.tests.nested_polymorphic.base import BaseNestedPolymorphicTestCase
11+
except ImportError:
12+
BaseNestedPolymorphicTestCase = TestCase
13+
has_polymorphic = False
14+
else:
15+
has_polymorphic = True
16+
17+
18+
class PolymorphicMixedNestingTestCase(BaseNestedPolymorphicTestCase):
19+
"""
20+
Tests for polymorphic inlines that are nested inside non-polymorphic inlines.
21+
"""
22+
root_model = Questionnaire
23+
24+
@classmethod
25+
def setUpClass(cls):
26+
if not has_polymorphic:
27+
raise SkipTest('django-polymorphic unavailable')
28+
super(PolymorphicMixedNestingTestCase, cls).setUpClass()
29+
30+
def test_polymorphic_child_formset_rendering(self):
31+
"""The admin should only display one child inline for polymorphic instances"""
32+
obj = self.root_model.objects.create(title='test')
33+
self.load_admin(obj)
34+
step_indexes = self.add_inline(model=SurveyStep, title='Step 1')
35+
self.add_inline(step_indexes, model=BlockMarkdown, value="Some instructions")
36+
group_indexes = self.add_inline(step_indexes, model=BlockRadioGroup, label="Select one")
37+
self.add_inline(group_indexes, BlockRadioButton, label="Choice 1")
38+
self.add_inline(group_indexes, BlockRadioButton, label="Choice 2")
39+
40+
inline_ids = [
41+
el.get_attribute('id') for el in
42+
self.selenium.find_elements_by_css_selector(
43+
'.djn-group:not([id*="-empty-"]')]
44+
45+
expected_inline_ids = [
46+
"surveystep_set-group",
47+
"surveystep_set-0-block_set-group",
48+
"surveystep_set-0-block_set-1-blockradiobutton_set-group"]
49+
50+
assert inline_ids == expected_inline_ids
51+
52+
self.save_form()
53+
54+
inline_ids = [
55+
el.get_attribute('id') for el in
56+
self.selenium.find_elements_by_css_selector(
57+
'.djn-group:not([id*="-empty-"]')]
58+
59+
assert "surveystep_set-0-block_set-0-blockradiobutton_set-group" not in inline_ids, (
60+
"Nested polymorphic model erroneously has inlines for both child models")
61+
assert inline_ids == expected_inline_ids
62+
63+
def test_add_step_to_empty_survey(self):
64+
survey = self.root_model.objects.create(title='test')
65+
self.load_admin(survey)
66+
self.add_inline(model=SurveyStep, title='Step 1')
67+
self.save_form()
68+
69+
steps = survey.surveystep_set.all()
70+
71+
assert len(steps) == 1
72+
assert steps[0].title == "Step 1"
73+
74+
def test_add_blocks_to_empty_survey(self):
75+
survey = self.root_model.objects.create(title='test')
76+
self.load_admin(survey)
77+
step_indexes = self.add_inline(model=SurveyStep, title='Step 1')
78+
self.add_inline(step_indexes, model=BlockMarkdown, value="Some instructions")
79+
group_indexes = self.add_inline(step_indexes, model=BlockRadioGroup, label="Select one")
80+
self.add_inline(group_indexes, BlockRadioButton, label="Choice 1")
81+
self.add_inline(group_indexes, BlockRadioButton, label="Choice 2")
82+
self.save_form()
83+
84+
steps = survey.surveystep_set.all()
85+
86+
assert len(steps) == 1
87+
assert steps[0].title == "Step 1"
88+
89+
blocks = Block.objects.filter(survey_step=steps[0])
90+
assert len(blocks) == 2
91+
assert isinstance(blocks[0], BlockMarkdown)
92+
assert isinstance(blocks[1], BlockRadioGroup)
93+
assert blocks[0].value == 'Some instructions'
94+
assert blocks[1].label == 'Select one'
95+
96+
buttons = blocks[1].blockradiobutton_set.all()
97+
assert len(buttons) == 2
98+
assert list(buttons.values_list('label', flat=True)) == ["Choice 1", "Choice 2"]
99+
100+
def test_add_blocks_to_existing_survey(self):
101+
survey = self.root_model.objects.create(title='test')
102+
step = SurveyStep.objects.create(survey=survey, title='Step 1', position=0)
103+
BlockMarkdown.objects.create(survey_step=step, position=0, value='Some instructions')
104+
group = BlockRadioGroup.objects.create(survey_step=step, position=1, label='Select one')
105+
106+
BlockRadioButton.objects.create(radio_group=group, position=0, label='Choice 1')
107+
BlockRadioButton.objects.create(radio_group=group, position=1, label='Choice 2')
108+
109+
self.load_admin(survey)
110+
111+
self.add_inline([[0, 0], [0, 1]], model=BlockRadioButton, label="Choice 3")
112+
self.add_inline([[0, 0]], model=BlockMarkdown, value="More instructions")
113+
114+
step_indexes = self.add_inline(model=SurveyStep, title='Step 2')
115+
self.add_inline(step_indexes, model=BlockMarkdown, value="Other instructions")
116+
group_indexes = self.add_inline(step_indexes, model=BlockRadioGroup, label="Please choose")
117+
self.add_inline(group_indexes, BlockRadioButton, label="Choice A")
118+
self.add_inline(group_indexes, BlockRadioButton, label="Choice B")
119+
self.save_form()
120+
121+
assert survey.serialize() == {
122+
"title": "test",
123+
"steps": [{
124+
"title": "Step 1",
125+
"blocks": [{
126+
"type": "markdown",
127+
"value": "Some instructions"
128+
}, {
129+
"type": "radiogroup",
130+
"label": "Select one",
131+
"buttons": ["Choice 1", "Choice 2", "Choice 3"],
132+
}, {
133+
"type": "markdown",
134+
"value": "More instructions"
135+
}],
136+
}, {
137+
"title": "Step 2",
138+
"blocks": [{
139+
"type": "markdown",
140+
"value": "Other instructions"
141+
}, {
142+
"type": "radiogroup",
143+
"label": "Please choose",
144+
"buttons": ["Choice A", "Choice B"],
145+
}],
146+
}],
147+
}

0 commit comments

Comments
 (0)