Repository: wagtail/wagtail

Issue:
Timezone handling buggy since 4.1.3/4.2.1
### Issue Summary

https://github.com/wagtail/wagtail/pull/9628#issuecomment-1450070061
Unfortunately, my fix from #9628 breaks something...

If you set your admin user's timezone to something differing from the default timezone, and then open the schedule-publication-dialog for a page, the set date will change with every save-request... **edit:** since the date fields are part of the form, every save just changes the dates in the form

I'm quite sure it has something to do with the deferred rendering of `TemplateResponse`s (see [django docs](https://docs.djangoproject.com/en/3.2/ref/template-response/#post-render-callbacks)).
In case of TemplateResponses, the set timezone is reset to the old value before the template is rendered...

I think we have to combine it with the similar handling for the current language here:
https://github.com/wagtail/wagtail/blob/fdc36a69891a92db6dca32703cddec1d573c193f/wagtail/admin/auth.py#L182-L207

I really don't know, why I have missed that language handling and the comments about the deferred rendering, sorry... :disappointed: 

Unfortunately I'm not able to make a PR before monday. The fix should be not that hard, it probably duplicates some of the language related code. As a quick, temporary alternative, you could of course revert the commits (which will probably break the tests, that have been fixed with #9628):

- https://github.com/wagtail/wagtail/commit/f05cbb2f0e62dc3a1f40f91da5a569175924e54e
- https://github.com/wagtail/wagtail/commit/b745dfd0dcf76c90e7c1603147865ed6586a64e4

### Steps to Reproduce

1. In your new virtualenv: `pip install wagtail && wagtail start mysite && cd mysite && pip install -r requirements.txt && ./manage.py makemigrations && ./manage.py migrate && ./manage.py createsuperuser && ./manage.py runserver`
2. Login to the CMS
3. Go to the account settings, and at least change the timezone to something other than UTC (the default timezone)
4. Add a new page (http://127.0.0.1:8000/admin/pages/add/home/homepage/3/) and save it as a draft.
5. Set a schedule by using the side panel and configure a date in the future.
6. Open the schedule overlay again, the time should be different to the one you have used previously. You can repeat step 5 and 6 again and again, it will change the date everytime. You don't even have to use the overlay, since the date fields are part of the form, you can just save the draft.

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes

### Technical details

-   Python version: 3.11
-   Django version: 3.2, 4.1.7
-   Wagtail version: 4.1.3, 4.2.1
-   Browser version: Firefox 111



Discussion:
Hey, no worries! This is partly my fault too for not noticing it during the review. Thanks for catching it, though!

I don't think I'll make a PR before Monday either, but I wonder if this can be fixed by moving the `override_tz` context manager to also happen at the same line as the second locale `override()`?

https://github.com/wagtail/wagtail/blob/fdc36a69891a92db6dca32703cddec1d573c193f/wagtail/admin/auth.py#L198
I think it should be executed everywhere where the language is overridden, but also in the else block(?)

- https://github.com/wagtail/wagtail/blob/fdc36a69891a92db6dca32703cddec1d573c193f/wagtail/admin/auth.py#L183
- https://github.com/wagtail/wagtail/blob/fdc36a69891a92db6dca32703cddec1d573c193f/wagtail/admin/auth.py#L198
- https://github.com/wagtail/wagtail/blob/fdc36a69891a92db6dca32703cddec1d573c193f/wagtail/admin/auth.py#L207

But you're right, that should be enough, so no code duplication as I've initially thought 👍
It feels like the `if hasattr(response, "render"):` should probably be outside, e.g.

```py
with override_tz(time_zone), LogContext(user=user):
    if preferred_language:
        with override(preferred_language):
            response = view_func(request, *args, **kwargs)
    else:
        response = view_func(request, *args, **kwargs)
    
    if hasattr(response, "render"):
        render = response.render

        def overridden_render(response):
            with override_tz(time_zone):
                if preferred_language:
                    with override(preferred_language):
                        return render()
                return render()

        response.render = types.MethodType(
            overridden_render, response
        )
    return response
```
Nice, that's even better! 👍

Resolving patch:
diff --git a/wagtail/admin/auth.py b/wagtail/admin/auth.py
--- a/wagtail/admin/auth.py
+++ b/wagtail/admin/auth.py
@@ -182,29 +182,30 @@ def decorated_view(request, *args, **kwargs):
                     if preferred_language:
                         with override(preferred_language):
                             response = view_func(request, *args, **kwargs)
-
-                        if hasattr(response, "render"):
-                            # If the response has a render() method, Django treats it
-                            # like a TemplateResponse, so we should do the same
-                            # In this case, we need to guarantee that when the TemplateResponse
-                            # is rendered, it is done within the override context manager
-                            # or the user preferred_language will not be used
-                            # (this could be replaced with simply rendering the TemplateResponse
-                            # for simplicity but this does remove some of its middleware modification
-                            # potential)
-                            render = response.render
-
-                            def overridden_render(response):
-                                with override(preferred_language):
-                                    return render()
-
-                            response.render = types.MethodType(
-                                overridden_render, response
-                            )
-                            # decorate the response render method with the override context manager
-                        return response
                     else:
-                        return view_func(request, *args, **kwargs)
+                        response = view_func(request, *args, **kwargs)
+
+                    if hasattr(response, "render"):
+                        # If the response has a render() method, Django treats it
+                        # like a TemplateResponse, so we should do the same
+                        # In this case, we need to guarantee that when the TemplateResponse
+                        # is rendered, it is done within the override context manager
+                        # or the user preferred_language/timezone will not be used
+                        # (this could be replaced with simply rendering the TemplateResponse
+                        # for simplicity but this does remove some of its middleware modification
+                        # potential)
+                        render = response.render
+
+                        def overridden_render(response):
+                            with override_tz(time_zone):
+                                if preferred_language:
+                                    with override(preferred_language):
+                                        return render()
+                                return render()
+
+                        response.render = types.MethodType(overridden_render, response)
+                        # decorate the response render method with the override context manager
+                    return response
 
             except PermissionDenied:
                 if request.headers.get("x-requested-with") == "XMLHttpRequest":


Test patch:
diff --git a/wagtail/admin/tests/pages/test_edit_page.py b/wagtail/admin/tests/pages/test_edit_page.py
--- a/wagtail/admin/tests/pages/test_edit_page.py
+++ b/wagtail/admin/tests/pages/test_edit_page.py
@@ -444,6 +444,100 @@ def test_edit_post_scheduled(self):
             "This publishing schedule will only take effect after you have published",
         )
 
+    def test_edit_post_scheduled_custom_timezone(self):
+        # Set user's timezone to something different from the server timezone
+        UserProfile.objects.update_or_create(
+            user=self.user,
+            defaults={"current_time_zone": "Asia/Jakarta"},
+        )
+
+        post_data = {
+            "title": "I've been edited!",
+            "content": "Some content",
+            "slug": "hello-world",
+            "go_live_at": "2022-03-20 06:00",
+        }
+        edit_url = reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
+        response = self.client.post(edit_url, post_data, follow=True)
+        html = response.content.decode()
+
+        # Should be redirected to the edit page again
+        self.assertRedirects(response, edit_url, 302, 200)
+
+        child_page_new = SimplePage.objects.get(id=self.child_page.id)
+
+        # The page will still be live
+        self.assertTrue(child_page_new.live)
+
+        # A revision with approved_go_live_at should not exist
+        self.assertFalse(
+            Revision.page_revisions.filter(object_id=child_page_new.id)
+            .exclude(approved_go_live_at__isnull=True)
+            .exists()
+        )
+
+        # But a revision with go_live_at in their content json *should* exist
+        if settings.USE_TZ:
+            # The saved timestamp should be in UTC
+            self.assertTrue(
+                Revision.page_revisions.filter(
+                    object_id=child_page_new.id,
+                    content__go_live_at="2022-03-19T23:00:00Z",
+                ).exists()
+            )
+        else:
+            # Without TZ support, just use the submitted timestamp as-is
+            self.assertTrue(
+                Revision.page_revisions.filter(
+                    object_id=child_page_new.id,
+                    content__go_live_at="2022-03-20T06:00:00",
+                ).exists()
+            )
+
+        # Should show the draft go_live_at under the "Once published" label
+        # and should be in the user's timezone
+        self.assertContains(
+            response,
+            '<div class="w-label-3">Once published:</div>',
+            html=True,
+            count=1,
+        )
+        self.assertContains(
+            response,
+            '<span class="w-text-primary">Go-live:</span> March 20, 2022, 6 a.m.',
+            html=True,
+            count=1,
+        )
+
+        # Should show the "Edit schedule" button
+        self.assertTagInHTML(
+            '<button type="button" data-a11y-dialog-show="schedule-publishing-dialog">Edit schedule</button>',
+            html,
+            count=1,
+            allow_extra_attrs=True,
+        )
+
+        # Should show the dialog template pointing to the [data-edit-form] selector as the root
+        self.assertTagInHTML(
+            '<div id="schedule-publishing-dialog" class="w-dialog publishing" data-dialog-root-selector="[data-edit-form]">',
+            html,
+            count=1,
+            allow_extra_attrs=True,
+        )
+
+        # Should show the input with the correct value in the user's timezone
+        self.assertTagInHTML(
+            '<input type="text" name="go_live_at" value="2022-03-20 06:00">',
+            html,
+            count=1,
+            allow_extra_attrs=True,
+        )
+
+        self.assertContains(
+            response,
+            "This publishing schedule will only take effect after you have published",
+        )
+
     def test_schedule_panel_without_publish_permission(self):
         editor = self.create_user("editor", password="password")
         editor.groups.add(Group.objects.get(name="Editors"))