Opened 3 months ago

Closed 3 months ago

#35909 closed Bug (invalid)

Mutation of FormMixin.initial can cause a FormView with a formset to crash

Reported by: David Sanders Owned by:
Component: Generic views Version: 5.1
Severity: Normal Keywords: FormView FormSet
Cc: Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no
Pull Requests:How to create a pull request

Description (last modified by David Sanders)

Hi Djangonauts, long time no see… have been super busy, hope you're all well!

Here's an interesting problem with some backstory for which I never knew Django behaved like this.

So some of the old timers may know that FormMixin.initial is a mutable class attribute, and there have been past bug reports concerning this, the main one from 13 years ago is #16138

The fix for this was to call .copy() during get_initial().

However, I just spent a couple of days tracking down and fixing a 500 which was caused by this 😅 The direct cause for this was a junior dev mutating initial directly, not realising that because it is a class attribute then it's a long-living mutation.

Example

Given this simple example model setup:

class Foo(Model):
    foo = IntegerField()

class Bar(Model):
    foo = IntegerField()

and these 2 views:

class FooCreateView(CreateView):
    model = Foo
    fields = ["foo"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.initial["foo"] = 2

class BarCreateView(CreateView):
    model = Bar
    fields = ["foo"]

If you first visit FooCreateView, then visit BarCreateView; then this will have the unintended side-effect of initialising BarCreateView with 2.

This alone doesn't cause a 500 but it is a source of potential bug that may go unnoticed!

Furthermore if you create a FormView with a model formset, this will cause it to 500!

BarFormSet = modelformset_factory(Bar, fields=["foo"])

class LotsOfBarCreateView(FormView):
    template_name = "bars.html"
    form_class = BarFormSet

The unintended initialisation is incompatible with the way initialisation works with formsets - formsets expect a list, not a dictionary - see the traceback below

Potential Solution?

If we simply move the initialisation of initial to an __init__() the problem goes away. Furthermore I ran the full test suite with this change and nothing seems to break.

Claude seems to the only active contributor that was part of the original ticket - Would you (or anyone else) know why the solution wasn't to initialise from __init__() to begin with? I did see mention of it in a duplicate ticket #16701. The original ticket appears to be from prior GitHub times so there's no PR discussion to review.

index ebd071cf00..bff9ac68ce 100644
--- a/django/views/generic/edit.py
+++ b/django/views/generic/edit.py
@@ -13,11 +13,15 @@ from django.views.generic.detail import (
 class FormMixin(ContextMixin):
     """Provide a way to show and handle a form in a request."""

-    initial = {}
+    initial = None
     form_class = None
     success_url = None
     prefix = None

+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.initial = {}
+
     def get_initial(self):
         """Return the initial data to use for forms on this view."""
         return self.initial.copy()

Traceback

Here's the traceback:

Traceback (most recent call last):
  File "/path/to/test-project/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
               ^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/core/handlers/base.py", line 220, in _get_response
    response = response.render()
               ^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/response.py", line 114, in render
    self.content = self.rendered_content
                   ^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/response.py", line 92, in rendered_content
    return template.render(context, self._request)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/backends/django.py", line 107, in render
    return self.template.render(context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 171, in render
    return self._render(context)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 163, in _render
    return self.nodelist.render(context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 1016, in render
    return SafeString("".join([node.render_annotated(context) for node in self]))
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 1016, in <listcomp>
    return SafeString("".join([node.render_annotated(context) for node in self]))
                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 977, in render_annotated
    return self.render(context)
           ^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 1081, in render
    return render_value_in_context(output, context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 1058, in render_value_in_context
    value = str(value)
            ^^^^^^^^^^
  File "/path/to/test-project/django/forms/utils.py", line 55, in render
    return mark_safe(renderer.render(template, context))
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/forms/renderers.py", line 29, in render
    return template.render(context, request=request).strip()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/backends/django.py", line 107, in render
    return self.template.render(context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 171, in render
    return self._render(context)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 163, in _render
    return self.nodelist.render(context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 1016, in render
    return SafeString("".join([node.render_annotated(context) for node in self]))
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 1016, in <listcomp>
    return SafeString("".join([node.render_annotated(context) for node in self]))
                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/base.py", line 977, in render_annotated
    return self.render(context)
           ^^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/template/defaulttags.py", line 199, in render
    len_values = len(values)
                 ^^^^^^^^^^^
  File "/path/to/test-project/django/forms/formsets.py", line 121, in __len__
    return len(self.forms)
               ^^^^^^^^^^
  File "/path/to/test-project/django/utils/functional.py", line 47, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
                                         ^^^^^^^^^^^^^^^^^^^
  File "/path/to/test-project/django/forms/formsets.py", line 205, in forms
    return [
           ^
  File "/path/to/test-project/django/forms/formsets.py", line 206, in <listcomp>
    self._construct_form(i, **self.get_form_kwargs(i))
  File "/path/to/test-project/django/forms/models.py", line 740, in _construct_form
    kwargs["initial"] = self.initial_extra[i - self.initial_form_count()]
                        ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyError: 0

Change History (3)

comment:1 by David Sanders, 3 months ago

Description: modified (diff)

comment:2 by David Sanders, 3 months ago

Description: modified (diff)

comment:3 by Sarah Boyce, 3 months ago

Resolution: invalid
Status: newclosed

Hi David, long time no see!

So it looks like we don't have tests covering setting the initial data but I would expect folks to either set the initial attribute or override get_initial(), I also feel like that's in the docs although there isn't an explicit example: https://docs.djangoproject.com/en/5.1/ref/class-based-views/mixins-editing/#formmixin

So in short, I think what they have done is not how it should be used and so I wouldn't call this a bug and would close as invalid

I would be open to adding more test coverage (and maybe doc updates to make the above clearer)
Something like (with better naming)...

  • TabularUnified tests/generic_views/test_edit.py

    a b from .models import Artist, Author  
    1414class FormMixinTests(SimpleTestCase):
    1515    request_factory = RequestFactory()
    1616
    17     def test_initial_data(self):
     17    def test_initial_data_independent(self):
    1818        """Test instance independence of initial data dict (see #16138)"""
    1919        initial_1 = FormMixin().get_initial()
    2020        initial_1["foo"] = "bar"
    2121        initial_2 = FormMixin().get_initial()
    2222        self.assertNotEqual(initial_1, initial_2)
    2323
     24    def test_set_initial_data(self):
     25        class InitialAttribute(FormMixin):
     26            initial = {"foo": "bar"}
     27
     28        class GetInitial(FormMixin):
     29            def get_initial(self):
     30                initial = super().get_initial()
     31                initial["foo"] = "bar"
     32                return initial
     33
     34        self.assertEqual(InitialAttribute().get_initial(), {"foo": "bar"})
     35        self.assertEqual(GetInitial().get_initial(), {"foo": "bar"})
     36
    2437    def test_get_prefix(self):
    2538        """Test prefix can be set (see #18872)"""
    2639        test_string = "test"
Note: See TracTickets for help on using tickets.
Back to Top