Repository: django/django

Issue:
# Fixed #36708 -- Initialized formset to None in ChangeList.__init__().

#### Trac ticket number

ticket-36708

#### Branch description

This PR adds a default formset = None attribute to the ChangeList class.
The Ticket describes how when Django instantiates ChangeList through ModelAdmin, it assigns formset dynamically.
However, when developers create their own custom ChangeList instances, formset may not be set, leading to an AttributeError in templates.
Defining formset as a class-level field (attribute) makes sure that behaviour is a bit more consistent 

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 6c202c8e613c..2de07fde7e33 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -2050,11 +2050,6 @@ def changelist_view(self, request, extra_context=None):
             # me back" button on the action confirmation page.
             return HttpResponseRedirect(request.get_full_path())
 
-        # If we're allowing changelist editing, we need to construct a formset
-        # for the changelist given all the fields to be edited. Then we'll
-        # use the formset to validate/process POSTed data.
-        formset = cl.formset = None
-
         # Handle POSTed bulk-edit data.
         if request.method == "POST" and cl.list_editable and "_save" in request.POST:
             if not self.has_change_permission(request):
@@ -2063,13 +2058,11 @@ def changelist_view(self, request, extra_context=None):
             modified_objects = self._get_list_editable_queryset(
                 request, FormSet.get_default_prefix()
             )
-            formset = cl.formset = FormSet(
-                request.POST, request.FILES, queryset=modified_objects
-            )
-            if formset.is_valid():
+            cl.formset = FormSet(request.POST, request.FILES, queryset=modified_objects)
+            if cl.formset.is_valid():
                 changecount = 0
                 with transaction.atomic(using=router.db_for_write(self.model)):
-                    for form in formset.forms:
+                    for form in cl.formset.forms:
                         if form.has_changed():
                             obj = self.save_form(request, form, change=True)
                             self.save_model(request, obj, form, change=True)
@@ -2095,11 +2088,11 @@ def changelist_view(self, request, extra_context=None):
         # Handle GET -- construct a formset for display.
         elif cl.list_editable and self.has_change_permission(request):
             FormSet = self.get_changelist_formset(request)
-            formset = cl.formset = FormSet(queryset=cl.result_list)
+            cl.formset = FormSet(queryset=cl.result_list)
 
         # Build the list of media to be used by the formset.
-        if formset:
-            media = self.media + formset.media
+        if cl.formset:
+            media = self.media + cl.formset.media
         else:
             media = self.media
 
diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
index 8c9118808e8e..40a6b3bf3a86 100644
--- a/django/contrib/admin/views/main.py
+++ b/django/contrib/admin/views/main.py
@@ -101,7 +101,7 @@ def __init__(
         self.preserved_filters = model_admin.get_preserved_filters(request)
         self.sortable_by = sortable_by
         self.search_help_text = search_help_text
-
+        self.formset = None
         # Get search parameters from the query string.
         _search_form = self.search_form_class(request.GET)
         if not _search_form.is_valid():


Test patch:
diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py
index 68f6ca453eaf..319d6259f6a9 100644
--- a/tests/admin_changelist/tests.py
+++ b/tests/admin_changelist/tests.py
@@ -240,7 +240,6 @@ def test_result_list_empty_changelist_value(self):
         request.user = self.superuser
         m = ChildAdmin(Child, custom_site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )
@@ -285,7 +284,6 @@ def test_result_list_set_empty_value_display_on_admin_site(self):
         admin.site.empty_value_display = "???"
         m = ChildAdmin(Child, admin.site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )
@@ -310,7 +308,6 @@ def test_result_list_set_empty_value_display_in_model_admin(self):
         request.user = self.superuser
         m = EmptyValueChildAdmin(Child, admin.site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )
@@ -341,7 +338,6 @@ def test_result_list_html(self):
         request.user = self.superuser
         m = ChildAdmin(Child, custom_site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )
@@ -370,7 +366,6 @@ def test_action_checkbox_for_model_with_dunder_html(self):
         request = self._mocked_authenticated_request("/grandchild/", self.superuser)
         m = GrandChildAdmin(GrandChild, custom_site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )