Repository: wagtail/wagtail

Issue:
Preview error when using StreamField with EditHandler on per-request basis
### Issue Summary

We're using `EditHandler`s, which show/hide fields depending on the user's permission. This is based on the example in wagtail's tests (see https://github.com/wagtail/wagtail/blob/main/wagtail/tests/testapp/models.py#L1418).

**Problem:**
Unfortunately, there are multiple places in wagtail's codebase, where `BaseFormEditHandler.get_form_class()` is called, without binding the current request (and also instance etc). One example is the preview-mode (see [preview.py](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/views/pages/preview.py#L51)).

As a result, a user which can only view a subset of the full admin form of a page for example, actually validates his POST-data (which only contains a subset of the fields) with the full form, since the logic that would hide some fields based on the request isn't called.

This probably works for simple cases like [wagtail's test](https://github.com/wagtail/wagtail/blob/main/wagtail/tests/testapp/models.py#L1442) and described in multiple tickets (e.g. https://github.com/wagtail/wagtail/issues/5546), but in our case it results in crashes when showing/hiding `StreamField`s.

**Solution:**
At least bind `instance` and `request` before `get_form_class()` is called, where possible, similar to how the normal edit-view does it: https://github.com/wagtail/wagtail/blob/main/wagtail/admin/views/pages/edit.py#L311

I will try to add those calls to all the places I can find and let the tests run and I hope this can be fixed quickly.


### Steps to Reproduce

I don't have a simple example available yet, but basically we have a custom `BaseCompositeEditHandler` which may skip some of its children, if the user is not a superuser. This is very similar to the [sample](https://github.com/wagtail/wagtail/blob/main/wagtail/tests/testapp/models.py#L1393) in wagtail's tests, but instead of switching between two panel-lists, we have one default list, and based on the field-keys we may exclude some of those fields.

So this all works for the superuser, since he can edit all of the fields. But if one of those excluded fields is a `StreamField` and the non-superuser clicks on the preview button, the server crashes with something like that:

    2021-09-24 11:43:13 ERROR django.request: Internal Server Error: /admin/content/pages/add/base/testpage/1337/preview/ (log.py:224)
    Traceback (most recent call last):
      File "/****/lib/python3.8/site-packages/django/utils/datastructures.py", line 76, in __getitem__
        list_ = super().__getitem__(key)
    KeyError: 'form_element_blocks-count'

    During handling of the above exception, another exception occurred:

    Traceback (most recent call last):
    ...****...
        return view_func(request, *args, **kwargs)
      File "/****/lib/python3.8/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func
        response = view_func(request, *args, **kwargs)
      File "/****/lib/python3.8/site-packages/wagtail/admin/urls/__init__.py", line 121, in wrapper
        return view_func(request, *args, **kwargs)
      File "/****/lib/python3.8/site-packages/wagtail/admin/auth.py", line 193, in decorated_view
        return view_func(request, *args, **kwargs)
      File "/****/lib/python3.8/site-packages/django/views/generic/base.py", line 70, in view
        return self.dispatch(request, *args, **kwargs)
      File "/****/lib/python3.8/site-packages/django/views/generic/base.py", line 98, in dispatch
        return handler(request, *args, **kwargs)
      File "/****/lib/python3.8/site-packages/wagtail/admin/views/pages/preview.py", line 64, in post
        form = self.get_form(self.get_page(), request.POST)
      File "/****/lib/python3.8/site-packages/wagtail/admin/views/pages/preview.py", line 129, in get_form
        if form.is_valid():
      File "/****/lib/python3.8/site-packages/wagtail/admin/forms/pages.py", line 150, in is_valid
        return super().is_valid()
      File "/****/lib/python3.8/site-packages/modelcluster/forms.py", line 316, in is_valid
        form_is_valid = super(ClusterForm, self).is_valid()
      File "/****/lib/python3.8/site-packages/django/forms/forms.py", line 175, in is_valid
        return self.is_bound and not self.errors
      File "/****/lib/python3.8/site-packages/django/forms/forms.py", line 170, in errors
        self.full_clean()
      File "/****/lib/python3.8/site-packages/django/forms/forms.py", line 372, in full_clean
        self._clean_fields()
      File "/****/lib/python3.8/site-packages/django/forms/forms.py", line 384, in _clean_fields
        value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
      File "/****/lib/python3.8/site-packages/wagtail/core/blocks/base.py", line 535, in value_from_datadict
        return self.block_def.value_from_datadict(data, files, name)
      File "/****/lib/python3.8/site-packages/wagtail/core/blocks/stream_block.py", line 97, in value_from_datadict
        count = int(data['%s-count' % prefix])
      File "/****/lib/python3.8/site-packages/django/utils/datastructures.py", line 78, in __getitem__
        raise MultiValueDictKeyError(key)
    django.utils.datastructures.MultiValueDictKeyError: 'form_element_blocks-count'
    2021-09-24 11:43:13 ERROR django.channels.server: HTTP POST /admin/content/pages/add/base/testpage/1337/preview/ 500 [0.15, 127.0.0.1:44074] (runserver.py:160)

In the above stacktrace, the `form_element_blocks` (which is a `StreamField`) are not visible to the non-superuser user in the frontend. But the preview-request actually creates a form with the `StreamField` (because our code in `on_request_bound()` isn't called), which fails when validating against the POST-data, which doesn't contain any of the `StreamField`'s POST-data.


### Technical details

* Python version: 3.8.10
* Django version: 3.2.7
* Wagtail version: 2.14.1



Discussion:
Ok, `multiple places in wagtail's codebase` is what I've found after a quick search for "get_edit_handler().get_form_class()", but it seems that only the preview's usage is a real issue, since it can be overridden easily with custom edit handlers.

Actually I only found one place in the workflow's `EditView` (see https://github.com/wagtail/wagtail/blob/main/wagtail/admin/views/workflows.py#L141), where I'm not completely sure, if it should also bind the form to the current object, just to avoid bugs in the future. But I'm not really an expert here, because I haven't used workflows intensively yet.

Resolving patch:
diff --git a/wagtail/admin/views/pages/preview.py b/wagtail/admin/views/pages/preview.py
--- a/wagtail/admin/views/pages/preview.py
+++ b/wagtail/admin/views/pages/preview.py
@@ -48,7 +48,7 @@ def get_page(self):
                                  id=self.kwargs["page_id"]).get_latest_revision_as_page()
 
     def get_form(self, page, query_dict):
-        form_class = page.get_edit_handler().get_form_class()
+        form_class = page.get_edit_handler().bind_to(instance=page, request=self.request).get_form_class()
         parent_page = page.get_parent().specific
 
         if self.session_key not in self.request.session:


Test patch:
diff --git a/wagtail/admin/tests/pages/test_preview.py b/wagtail/admin/tests/pages/test_preview.py
--- a/wagtail/admin/tests/pages/test_preview.py
+++ b/wagtail/admin/tests/pages/test_preview.py
@@ -1,13 +1,17 @@
 import datetime
 
+from functools import wraps
+from unittest import mock
+
 from django.test import TestCase
 from django.urls import reverse
 from django.utils import timezone
 from freezegun import freeze_time
 
+from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface
 from wagtail.admin.views.pages.preview import PreviewOnEdit
 from wagtail.core.models import Page
-from wagtail.tests.testapp.models import EventCategory, SimplePage, StreamPage
+from wagtail.tests.testapp.models import EventCategory, EventPage, SimplePage, StreamPage
 from wagtail.tests.utils import WagtailTestUtils
 
 
@@ -57,6 +61,21 @@ def test_issue_2599(self):
         self.assertEqual(response.context['self'].get_parent(), homepage)
 
 
+def clear_edit_handler(page_cls):
+    def decorator(fn):
+        @wraps(fn)
+        def decorated(*args, **kwargs):
+            # Clear any old EditHandlers generated
+            page_cls.get_edit_handler.cache_clear()
+            try:
+                fn(*args, **kwargs)
+            finally:
+                # Clear the bad EditHandler generated just now
+                page_cls.get_edit_handler.cache_clear()
+        return decorated
+    return decorator
+
+
 class TestPreview(TestCase, WagtailTestUtils):
     fixtures = ['test.json']
 
@@ -174,6 +193,83 @@ def test_preview_on_edit_expiry(self):
             response = self.client.get(preview_url)
             self.assertEqual(response.status_code, 200)
 
+    @clear_edit_handler(EventPage)
+    def test_preview_with_custom_edit_handler(self):
+        """
+        The test is based on TestPreview.test_preview_on_create_with_m2m_field, except that the "categories"
+        FieldPanel is only visible to superusers. Non-superusers should not be able to set "categories" for
+        the preview.
+        """
+
+        class SuperuserEventCategoriesObjectList(ObjectList):
+            def on_request_bound(self):
+                new_children = []
+                for child in self.children:
+                    # skip the "categories" FieldPanel for non-superusers
+                    if isinstance(child, FieldPanel) and child.field_name == "categories" and not self.request.user.is_superuser:
+                        continue
+
+                    new_child = child.bind_to(
+                        model=self.model,
+                        instance=self.instance,
+                        request=self.request,
+                        form=self.form,
+                    )
+                    new_children.append(new_child)
+                self.children = new_children
+
+        new_tabbed_interface = TabbedInterface([
+            SuperuserEventCategoriesObjectList(EventPage.content_panels),
+            ObjectList(EventPage.promote_panels),
+        ])
+
+        with mock.patch.object(EventPage, 'edit_handler', new=new_tabbed_interface, create=True):
+            # Non-superusers should not see categories panel, so even though "post_data" contains "categories",
+            # it should not be considered for the preview request.
+            self.login(username='siteeditor', password='password')
+
+            preview_url = reverse('wagtailadmin_pages:preview_on_add',
+                                  args=('tests', 'eventpage', self.home_page.id))
+            response = self.client.post(preview_url, self.post_data)
+
+            # Check the JSON response
+            self.assertEqual(response.status_code, 200)
+            self.assertJSONEqual(response.content.decode(), {'is_valid': True})
+
+            # Check the user can refresh the preview
+            preview_session_key = 'wagtail-preview-tests-eventpage-{}'.format(self.home_page.id)
+            self.assertTrue(preview_session_key in self.client.session)
+
+            response = self.client.get(preview_url)
+
+            # Check the HTML response
+            self.assertEqual(response.status_code, 200)
+            self.assertTemplateUsed(response, 'tests/event_page.html')
+            self.assertContains(response, "Beach party")
+            self.assertNotContains(response, "<li>Parties</li>")
+            self.assertNotContains(response, "<li>Holidays</li>")
+
+            # Since superusers see the "categories" panel, the posted data should be used for the preview.
+            self.login(username='superuser', password='password')
+            response = self.client.post(preview_url, self.post_data)
+
+            # Check the JSON response
+            self.assertEqual(response.status_code, 200)
+            self.assertJSONEqual(response.content.decode(), {'is_valid': True})
+
+            # Check the user can refresh the preview
+            preview_session_key = 'wagtail-preview-tests-eventpage-{}'.format(self.home_page.id)
+            self.assertTrue(preview_session_key in self.client.session)
+
+            response = self.client.get(preview_url)
+
+            # Check the HTML response
+            self.assertEqual(response.status_code, 200)
+            self.assertTemplateUsed(response, 'tests/event_page.html')
+            self.assertContains(response, "Beach party")
+            self.assertContains(response, "<li>Parties</li>")
+            self.assertContains(response, "<li>Holidays</li>")
+
 
 class TestDisablePreviewButton(TestCase, WagtailTestUtils):
     """