Repository: wagtail/wagtail

Issue:
CommentPanel breaks assertCanCreate in existing tests
The addition of CommentPanel to the base Page panels definition in 1ca48eb31f9a3bcf673d78379f5f23f7d727c639 adds a formset to the posted data; this means that any existing test code that posts to the page edit view (for a page model that does not override this) now fails with

    django.core.exceptions.ValidationError: ['ManagementForm data is missing or has been tampered with']

This issue has been masked in 63c536eef3525d9d4bb322b3740078694570c9a2 by modifying `settings_panels` on all test page models, but this is not a viable option in real-world projects, and posting to the page edit view in tests is standard practice as codified by [the assertCanCreate method](https://docs.wagtail.io/en/latest/advanced_topics/testing.html#wagtail.tests.utils.WagtailPageTests.assertCanCreate). An example of a project where this is failing on current main is wagtail-webstories:

https://github.com/torchbox/wagtail-webstories/blob/main/tests/tests/test_editing.py#L24-L57

At minimum, we need to update documentation and add an upgrade note to tell developers to add the comment formset to their postdata; preferably, we should update the CommentPanel logic to gracefully handle the absence of this formset in the postdata.


Discussion:

Resolving patch:
diff --git a/wagtail/admin/edit_handlers.py b/wagtail/admin/edit_handlers.py
--- a/wagtail/admin/edit_handlers.py
+++ b/wagtail/admin/edit_handlers.py
@@ -852,7 +852,9 @@ def user_data(user):
         user_pks = {user.pk}
         serialized_comments = []
         bound = self.form.is_bound
-        for form in self.form.formsets['comments'].forms if self.form else []:
+        comment_formset = self.form.formsets.get('comments')
+        comment_forms = comment_formset.forms if comment_formset else []
+        for form in comment_forms:
             # iterate over comments to retrieve users (to get display names) and serialized versions
             replies = []
             for reply_form in form.formsets['replies'].forms:
diff --git a/wagtail/admin/forms/pages.py b/wagtail/admin/forms/pages.py
--- a/wagtail/admin/forms/pages.py
+++ b/wagtail/admin/forms/pages.py
@@ -118,6 +118,7 @@ def __init__(self, data=None, files=None, parent_page=None, subscription=None, *
             initial['comment_notifications'] = subscription.comment_notifications
 
         super().__init__(data, files, *args, initial=initial, **kwargs)
+
         self.parent_page = parent_page
 
     def save(self, commit=True):
@@ -129,6 +130,19 @@ def save(self, commit=True):
 
         return super().save(commit=commit)
 
+    def is_valid(self):
+        comments = self.formsets.get('comments')
+        # Remove the comments formset if the management form is invalid
+        if comments:
+            try:
+                # As of Django 3.2, this no longer raises an error
+                has_form = comments.management_form.is_valid()
+            except forms.ValidationError:
+                has_form = False
+            if not has_form:
+                del self.formsets['comments']
+        return super().is_valid()
+
     def clean(self):
         cleaned_data = super().clean()
         if 'slug' in self.cleaned_data:


Test patch:
diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py
--- a/wagtail/tests/testapp/models.py
+++ b/wagtail/tests/testapp/models.py
@@ -20,8 +20,8 @@
 from taggit.models import ItemBase, TagBase, TaggedItemBase
 
 from wagtail.admin.edit_handlers import (
-    CommentPanel, FieldPanel, InlinePanel, MultiFieldPanel, ObjectList, PageChooserPanel,
-    StreamFieldPanel, TabbedInterface)
+    FieldPanel, InlinePanel, MultiFieldPanel, ObjectList, PageChooserPanel, StreamFieldPanel,
+    TabbedInterface)
 from wagtail.admin.forms import WagtailAdminPageForm
 from wagtail.admin.mail import send_mail
 from wagtail.contrib.forms.forms import FormBuilder
@@ -61,12 +61,6 @@
 )
 
 
-SETTINGS_PANELS_WITHOUT_COMMENTING = [
-    panel for panel in Page.settings_panels
-    if not isinstance(panel, CommentPanel)
-]
-
-
 # Link fields
 
 class LinkFields(models.Model):
@@ -152,9 +146,6 @@ class SimplePage(Page):
         FieldPanel('content'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
     def get_admin_display_title(self):
         return "%s (simple page)" % super().get_admin_display_title()
 
@@ -174,9 +165,6 @@ class PageWithExcludedCopyField(Page):
         FieldPanel('content'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class PageWithOldStyleRouteMethod(Page):
     """
@@ -200,9 +188,6 @@ class FilePage(Page):
         FieldPanel('file_field'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 # Event page
 
@@ -343,9 +328,6 @@ class EventPage(Page):
         ImageChooserPanel('feed_image'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class HeadCountRelatedModelUsingPK(models.Model):
     """Related model that uses a custom primary key (pk) not id"""
@@ -372,9 +354,6 @@ class FormClassAdditionalFieldPage(Page):
         FieldPanel('code'),  # not in model, see set base_form_class
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
     base_form_class = FormClassAdditionalFieldPageForm
 
 
@@ -409,9 +388,6 @@ def get_admin_display_title(self):
 
     content_panels = [FieldPanel('excerpt')] + EventPage.content_panels
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 # "custom" sitemap object
 class EventSitemap(Sitemap):
@@ -484,9 +460,6 @@ def get_cached_paths(self):
         FieldPanel('intro', classname="full"),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class FormField(AbstractFormField):
     page = ParentalKey('FormPage', related_name='form_fields', on_delete=models.CASCADE)
@@ -515,9 +488,6 @@ def get_context(self, request):
         ], "Email")
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 # FormPage with a non-HTML extension
 
@@ -538,9 +508,6 @@ class JadeFormPage(AbstractEmailForm):
         ], "Email")
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 # Form page that redirects to a different page
 
@@ -582,9 +549,6 @@ def render_landing_page(self, request, form_submission=None, *args, **kwargs):
         ], "Email")
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 # FormPage with a custom FormSubmission
 
@@ -655,9 +619,6 @@ def serve(self, request, *args, **kwargs):
         ], "Email")
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class FormFieldWithCustomSubmission(AbstractFormField):
     page = ParentalKey(FormPageWithCustomSubmission, on_delete=models.CASCADE, related_name='custom_form_fields')
@@ -718,9 +679,6 @@ def get_data_fields(self):
         ], "Email")
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 # FormPage with cutom FormBuilder
 
@@ -769,9 +727,6 @@ class FormPageWithCustomFormBuilder(AbstractEmailForm):
         ], "Email")
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 # Snippets
 class AdvertPlacement(models.Model):
@@ -881,9 +836,6 @@ class StandardIndex(Page):
 
     promote_panels = []
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class StandardChild(Page):
     pass
@@ -935,9 +887,6 @@ class TaggedPage(Page):
         FieldPanel('tags'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class SingletonPage(Page):
     @classmethod
@@ -1117,9 +1066,6 @@ class StreamPage(Page):
 
     preview_modes = []
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class DefaultStreamPage(Page):
     body = StreamField([
@@ -1133,9 +1079,6 @@ class DefaultStreamPage(Page):
         StreamFieldPanel('body'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class MTIBasePage(Page):
     is_creatable = False
@@ -1306,9 +1249,6 @@ class DefaultRichTextFieldPage(Page):
         FieldPanel('body'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class DefaultRichBlockFieldPage(Page):
     body = StreamField([
@@ -1328,9 +1268,6 @@ class CustomRichTextFieldPage(Page):
         FieldPanel('body'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class CustomRichBlockFieldPage(Page):
     body = StreamField([
@@ -1342,9 +1279,6 @@ class CustomRichBlockFieldPage(Page):
         StreamFieldPanel('body'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class RichTextFieldWithFeaturesPage(Page):
     body = RichTextField(features=['quotation', 'embed', 'made-up-feature'])
@@ -1354,9 +1288,6 @@ class RichTextFieldWithFeaturesPage(Page):
         FieldPanel('body'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 # a page that only contains RichTextField within an InlinePanel,
 # to test that the inline child's form media gets pulled through
@@ -1375,9 +1306,6 @@ class SectionedRichTextPage(Page):
         InlinePanel('sections')
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class InlineStreamPageSection(Orderable):
     page = ParentalKey('tests.InlineStreamPage', related_name='sections', on_delete=models.CASCADE)
@@ -1397,18 +1325,12 @@ class InlineStreamPage(Page):
         InlinePanel('sections')
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class TableBlockStreamPage(Page):
     table = StreamField([('table', TableBlock())])
 
     content_panels = [StreamFieldPanel('table')]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class UserProfile(models.Model):
     # Wagtail's schema must be able to coexist alongside a custom UserProfile model
@@ -1593,9 +1515,6 @@ class RestaurantPage(Page):
         FieldPanel('tags'),
     ]
 
-    # FIXME: Workaround to fix management form errors in tests caused by CommentPanel
-    settings_panels = SETTINGS_PANELS_WITHOUT_COMMENTING
-
 
 class RestaurantTag(TagBase):
     free_tagging = False