Add missing TZ conversions and date formatting

* Usages of strftime("%d %b %Y %H:%M") have been replaced by usages of
  django's timezone handling and date formatting, so the output is
  similar to other dates rendered through the templating.
* Dates stored in the action logs are now parsed and converted
  accordingly for the frontend.
* Improved tests to check those conversions
* Fixes 
* Add handling for wagtail.schedule.cancel with go_live_at=None
* Migrate log timestamps to ISO 8601 in UTC
* adapted new code from 
* replaced usages of test utility rendered_timestamp() with new core utility
* Replaced usages of django.utils.timezone.utc
* re-added migration on top of newest migrations
pull/10620/head
Stefan Hammer 2022-11-03 12:08:14 +01:00 zatwierdzone przez LB (Ben Johnston)
rodzic f866cd1608
commit 7962118dc0
19 zmienionych plików z 385 dodań i 109 usunięć

Wyświetl plik

@ -44,6 +44,8 @@ Changelog
* Fix: Improve accessibility for header search, remove autofocus on page load, advise screen readers that content has changed when results update (LB (Ben) Johnston)
* Fix: Fix incorrect override of `PagePermissionHelper.user_can_unpublish_obj()` in ModelAdmin (Sébastien Corbin)
* Fix: Prevent memory exhaustion when updating a large number of image renditions (Jake Howard)
* Fix: Add missing Time Zone conversions and date formatting throughout the admin (Stefan Hammer)
* Fix: Ensure that audit logs and revisions are consistently use UTC and add migration for existing entries (Stefan Hammer)
* Docs: Document how to add non-ModelAdmin views to a `ModelAdminGroup` (Onno Timmerman)
* Docs: Document how to add StructBlock data to a StreamField (Ramon Wenger)
* Docs: Update ReadTheDocs settings to v2 to resolve urllib3 issue in linkcheck extension (Thibaud Colas)

Wyświetl plik

@ -78,6 +78,8 @@ The `wagtail start` command now supports an optional `--template` argument that
* Improve accessibility for header search, remove autofocus on page load, advise screen readers that content has changed when results update (LB (Ben) Johnston)
* Fix incorrect override of `PagePermissionHelper.user_can_unpublish_obj()` in ModelAdmin (Sébastien Corbin)
* Prevent memory exhaustion when updating a large number of image renditions (Jake Howard)
* Add missing Time Zone conversions and date formatting throughout the admin (Stefan Hammer)
* Ensure that audit logs and revisions are consistently use UTC and add migration for existing entries (Stefan Hammer)
### Documentation

Wyświetl plik

@ -7,6 +7,7 @@ from django.utils import timezone
from wagtail.log_actions import log
from wagtail.permission_policies.base import ModelPermissionPolicy
from wagtail.signals import published
from wagtail.utils.timestamps import ensure_utc
logger = logging.getLogger("wagtail")
@ -63,8 +64,8 @@ class PublishRevisionAction:
data={
"revision": {
"id": self.revision.id,
"created": self.revision.created_at.strftime("%d %b %Y %H:%M"),
"go_live_at": self.object.go_live_at.strftime("%d %b %Y %H:%M"),
"created": ensure_utc(self.revision.created_at),
"go_live_at": ensure_utc(self.object.go_live_at),
"has_live_version": self.object.live,
}
},
@ -162,9 +163,7 @@ class PublishRevisionAction:
data = {
"revision": {
"id": previous_revision.id,
"created": previous_revision.created_at.strftime(
"%d %b %Y %H:%M"
),
"created": ensure_utc(previous_revision.created_at),
}
}

Wyświetl plik

@ -43,8 +43,9 @@ from wagtail.test.testapp.models import (
)
from wagtail.test.utils import WagtailTestUtils
from wagtail.test.utils.form_data import inline_formset, nested_form_data
from wagtail.test.utils.timestamps import rendered_timestamp, submittable_timestamp
from wagtail.test.utils.timestamps import submittable_timestamp
from wagtail.users.models import UserProfile
from wagtail.utils.timestamps import render_timestamp
class TestPageEdit(WagtailTestUtils, TestCase):
@ -411,13 +412,13 @@ class TestPageEdit(WagtailTestUtils, TestCase):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -765,13 +766,13 @@ class TestPageEdit(WagtailTestUtils, TestCase):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -929,13 +930,13 @@ class TestPageEdit(WagtailTestUtils, TestCase):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -1099,7 +1100,7 @@ class TestPageEdit(WagtailTestUtils, TestCase):
# Should still show the active expire_at in the live object
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -1113,13 +1114,13 @@ class TestPageEdit(WagtailTestUtils, TestCase):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(new_expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
html=True,
count=1,
)
@ -1210,7 +1211,7 @@ class TestPageEdit(WagtailTestUtils, TestCase):
# override the existing expire_at when it goes live
self.assertNotContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
)
@ -1222,13 +1223,13 @@ class TestPageEdit(WagtailTestUtils, TestCase):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(new_expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
html=True,
count=1,
)
@ -1313,7 +1314,7 @@ class TestPageEdit(WagtailTestUtils, TestCase):
# unpublished (expired) -> published (scheduled) -> unpublished (expired again)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -1326,13 +1327,13 @@ class TestPageEdit(WagtailTestUtils, TestCase):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(new_expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
html=True,
count=1,
)

Wyświetl plik

@ -148,7 +148,16 @@ class TestRevisions(WagtailTestUtils, TestCase):
reverse("wagtailadmin_pages:history", args=(self.christmas_event.id,))
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Page scheduled for publishing at 26 Dec 2014")
if settings.USE_TZ:
# the default timezone is "Asia/Tokyo", so we expect UTC +9
expected_date_string = "Dec. 26, 2014, 9 p.m."
else:
expected_date_string = "Dec. 26, 2014, noon"
self.assertContains(
response, f"Page scheduled for publishing at {expected_date_string}"
)
self.assertContains(response, this_christmas_unschedule_url)

Wyświetl plik

@ -10,6 +10,7 @@ from freezegun import freeze_time
from wagtail.models import GroupPagePermission, Page, PageLogEntry, PageViewRestriction
from wagtail.test.testapp.models import SimplePage
from wagtail.test.utils import WagtailTestUtils
from wagtail.utils.timestamps import render_timestamp
class TestAuditLogAdmin(WagtailTestUtils, TestCase):
@ -286,7 +287,7 @@ class TestAuditLogAdmin(WagtailTestUtils, TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(
response,
f"Page unscheduled for publishing at {go_live_at.strftime('%d %b %Y %H:%M')}",
f"Page unscheduled for publishing at {render_timestamp(go_live_at)}",
)
def test_page_history_after_unscheduled_revision(self):
@ -325,5 +326,5 @@ class TestAuditLogAdmin(WagtailTestUtils, TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(
response,
f"Revision {revision.id} from {revision.created_at.strftime('%d %b %Y %H:%M')} unscheduled from publishing at {go_live_at.strftime('%d %b %Y %H:%M')}.",
f"Revision {revision.id} from {render_timestamp(revision.created_at)} unscheduled from publishing at {render_timestamp(go_live_at)}.",
)

Wyświetl plik

@ -31,6 +31,7 @@ from wagtail.models import (
WorkflowMixin,
WorkflowState,
)
from wagtail.utils.timestamps import render_timestamp
class HookResponseMixin:
@ -702,7 +703,7 @@ class RevisionsRevertMixin:
)
message_data = {
"model_name": capfirst(self.model._meta.verbose_name),
"created_at": self.revision.created_at.strftime("%d %b %Y %H:%M"),
"created_at": render_timestamp(self.revision.created_at),
"user": user_avatar,
}
message = mark_safe(message_string % message_data)
@ -747,7 +748,7 @@ class RevisionsRevertMixin:
return message % {
"model_name": capfirst(self.model._meta.verbose_name),
"object": self.object,
"timestamp": self.revision.created_at.strftime("%d %b %Y %H:%M"),
"timestamp": render_timestamp(self.revision.created_at),
}
def get_context_data(self, **kwargs):

Wyświetl plik

@ -30,6 +30,7 @@ from wagtail.models import (
PageSubscription,
WorkflowState,
)
from wagtail.utils.timestamps import render_timestamp
class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
@ -61,8 +62,8 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
"with version from %(previous_revision_datetime)s."
) % {
"page_title": self.page.get_admin_display_title(),
"previous_revision_datetime": self.previous_revision.created_at.strftime(
"%d %b %Y %H:%M"
"previous_revision_datetime": render_timestamp(
self.previous_revision.created_at
),
}
else:
@ -588,8 +589,8 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
"Version from %(previous_revision_datetime)s "
"of page '%(page_title)s' has been scheduled for publishing."
) % {
"previous_revision_datetime": self.previous_revision.created_at.strftime(
"%d %b %Y %H:%M"
"previous_revision_datetime": render_timestamp(
self.previous_revision.created_at
),
"page_title": self.page.get_admin_display_title(),
}
@ -615,9 +616,7 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
message = _(
"Version from %(datetime)s of page '%(page_title)s' has been published."
) % {
"datetime": self.previous_revision.created_at.strftime(
"%d %b %Y %H:%M"
),
"datetime": render_timestamp(self.previous_revision.created_at),
"page_title": self.page.get_admin_display_title(),
}
else:

Wyświetl plik

@ -18,6 +18,7 @@ from wagtail.admin.views.generic.models import (
)
from wagtail.admin.views.generic.preview import PreviewRevision
from wagtail.models import Page
from wagtail.utils.timestamps import render_timestamp
def revisions_index(request, page_id):
@ -72,7 +73,7 @@ def revisions_revert(request, page_id, revision_id):
"You are viewing a previous version of this page from <b>%(created_at)s</b> by %(user)s"
)
% {
"created_at": revision.created_at.strftime("%d %b %Y %H:%M"),
"created_at": render_timestamp(revision.created_at),
"user": user_avatar,
}
),

Wyświetl plik

@ -8,6 +8,7 @@ from django.utils.translation import gettext as _
from wagtail.admin.utils import get_latest_str, get_user_display_name
from wagtail.utils.deprecation import RemovedInWagtail60Warning
from wagtail.utils.timestamps import render_timestamp
class BaseLock:
@ -104,7 +105,7 @@ class BasicLock(BaseLock):
"<b>'{title}' was locked</b> by <b>you</b> on <b>{datetime}</b>."
),
title=title,
datetime=self.object.locked_at.strftime("%d %b %Y %H:%M"),
datetime=render_timestamp(self.object.locked_at),
)
else:
@ -122,7 +123,7 @@ class BasicLock(BaseLock):
),
title=title,
user=get_user_display_name(self.object.locked_by),
datetime=self.object.locked_at.strftime("%d %b %Y %H:%M"),
datetime=render_timestamp(self.object.locked_at),
)
else:
# Object was probably locked with an old version of Wagtail, or a script
@ -264,7 +265,7 @@ class ScheduledForPublishLock(BaseLock):
),
model_name=self.model_name,
title=scheduled_revision.object_str,
datetime=scheduled_revision.approved_go_live_at.strftime("%d %b %Y %H:%M"),
datetime=render_timestamp(scheduled_revision.approved_go_live_at),
)
return mark_safe(capfirst(message))

Wyświetl plik

@ -0,0 +1,137 @@
# Generated by Django 3.2.6 on 2022-11-09 07:50
import datetime
import django.core.serializers.json
from django.conf import settings
from django.db import migrations, models
from django.utils import timezone, dateparse
def legacy_to_iso_format(date_string, tz=None):
dt = datetime.datetime.strptime(date_string, "%d %b %Y %H:%M")
if settings.USE_TZ:
dt = timezone.make_aware(dt, datetime.timezone.utc if tz is None else tz)
dt = timezone.localtime(dt, datetime.timezone.utc)
# We return the datetime object, so DjangoJSONEncoder will serialize it accordingly.
return dt
def iso_to_legacy_format(date_string, tz=None):
dt = dateparse.parse_datetime(date_string)
if dt is None:
raise ValueError("date isn't well formatted")
if settings.USE_TZ:
dt = timezone.localtime(dt, datetime.timezone.utc if tz is None else tz)
return dt.strftime("%d %b %Y %H:%M")
def migrate_logs_with_created_only(model, converter):
for item in (
model.objects.filter(
action__in=["wagtail.revert", "wagtail.rename", "wagtail.publish"]
)
.only("data")
.iterator()
):
try:
# If a previous_revision was available, the data contains "revision" with
# its created date.
# Also, there are "wagtail.publish" logs, which don't set data at all.
created = item.data["revision"]["created"]
# "created" is set to the previous revision's created_at, which is set
# to UTC by django.
item.data["revision"]["created"] = converter(created)
except ValueError:
# TODO TBD: log error?
continue
except KeyError:
continue
else:
item.save(update_fields=["data"])
def migrate_schedule_logs(model, converter):
for item in (
model.objects.filter(
action__in=["wagtail.publish.schedule", "wagtail.schedule.cancel"]
)
.only("data")
.iterator()
):
created = item.data["revision"]["created"]
# May be unset for "wagtail.schedule.cancel"-logs.
go_live_at = item.data["revision"].get("go_live_at")
try:
# "created" is set to timezone.now() for new revisions ("wagtail.publish.schedule")
# and to self.created_at for "wagtail.schedule.cancel", which is set to UTC
# by django.
item.data["revision"]["created"] = converter(created)
if go_live_at:
# The go_live_at date is set to the revision object's "go_live_at".
# The revision's object is created by deserializing the json data (see wagtail.models.Revision.as_object()),
# and this process converts all datetime objects to the local timestamp (see https://github.com/wagtail/django-modelcluster/blob/8666f16eaf23ca98afc160b0a4729864411c0563/modelcluster/models.py#L109-L115).
# That's the reason, why this date is the only date, which is not stored in the log's JSON as UTC, but in the default timezone.
item.data["revision"]["go_live_at"] = converter(
go_live_at, tz=timezone.get_default_timezone()
)
except ValueError:
# TODO TBD: log error?
continue
else:
item.save(update_fields=["data"])
def migrate_custom_to_iso_format(apps, schema_editor):
ModelLogEntry = apps.get_model("wagtailcore.ModelLogEntry")
PageLogEntry = apps.get_model("wagtailcore.PageLogEntry")
migrate_logs_with_created_only(ModelLogEntry, legacy_to_iso_format)
migrate_logs_with_created_only(PageLogEntry, legacy_to_iso_format)
migrate_schedule_logs(ModelLogEntry, legacy_to_iso_format)
migrate_schedule_logs(PageLogEntry, legacy_to_iso_format)
def migrate_iso_to_custom_format(apps, schema_editor):
ModelLogEntry = apps.get_model("wagtailcore.ModelLogEntry")
PageLogEntry = apps.get_model("wagtailcore.PageLogEntry")
migrate_logs_with_created_only(ModelLogEntry, iso_to_legacy_format)
migrate_logs_with_created_only(PageLogEntry, iso_to_legacy_format)
migrate_schedule_logs(ModelLogEntry, iso_to_legacy_format)
migrate_schedule_logs(PageLogEntry, iso_to_legacy_format)
class Migration(migrations.Migration):
dependencies = [
("wagtailcore", "0087_alter_grouppagepermission_unique_together_and_more"),
]
operations = [
migrations.AlterField(
model_name="modellogentry",
name="data",
field=models.JSONField(
blank=True,
default=dict,
encoder=django.core.serializers.json.DjangoJSONEncoder,
),
),
migrations.AlterField(
model_name="pagelogentry",
name="data",
field=models.JSONField(
blank=True,
default=dict,
encoder=django.core.serializers.json.DjangoJSONEncoder,
),
),
migrations.RunPython(
migrate_custom_to_iso_format,
migrate_iso_to_custom_format,
),
]

Wyświetl plik

@ -92,6 +92,7 @@ from wagtail.signals import (
)
from wagtail.url_routing import RouteResult
from wagtail.utils.deprecation import RemovedInWagtail60Warning
from wagtail.utils.timestamps import ensure_utc
from .audit_log import ( # noqa: F401
BaseLogEntry,
@ -426,9 +427,7 @@ class RevisionMixin(models.Model):
data={
"revision": {
"id": previous_revision.id,
"created": previous_revision.created_at.strftime(
"%d %b %Y %H:%M"
),
"created": ensure_utc(previous_revision.created_at),
}
},
revision=revision,
@ -1702,9 +1701,7 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
data={
"revision": {
"id": previous_revision.id,
"created": previous_revision.created_at.strftime(
"%d %b %Y %H:%M"
),
"created": ensure_utc(previous_revision.created_at),
}
},
revision=revision,
@ -2775,8 +2772,8 @@ class Revision(models.Model):
data={
"revision": {
"id": self.id,
"created": self.created_at.strftime("%d %b %Y %H:%M"),
"go_live_at": object.go_live_at.strftime("%d %b %Y %H:%M")
"created": ensure_utc(self.created_at),
"go_live_at": ensure_utc(object.go_live_at)
if object.go_live_at
else None,
"has_live_version": object.live,

Wyświetl plik

@ -10,6 +10,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.utils import timezone
from django.utils.functional import cached_property
@ -162,7 +163,7 @@ class BaseLogEntry(models.Model):
label = models.TextField()
action = models.CharField(max_length=255, blank=True, db_index=True)
data = models.JSONField(blank=True, default=dict)
data = models.JSONField(blank=True, default=dict, encoder=DjangoJSONEncoder)
timestamp = models.DateTimeField(verbose_name=_("timestamp (UTC)"), db_index=True)
uuid = models.UUIDField(
blank=True,

Wyświetl plik

@ -3,6 +3,7 @@ import json
from io import StringIO
from unittest import mock
from django.conf import settings
from django.contrib.admin.utils import quote
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, Permission
@ -66,7 +67,8 @@ from wagtail.test.testapp.models import (
VariousOnDeleteModel,
)
from wagtail.test.utils import WagtailTestUtils
from wagtail.test.utils.timestamps import rendered_timestamp, submittable_timestamp
from wagtail.test.utils.timestamps import submittable_timestamp
from wagtail.utils.timestamps import render_timestamp
class TestSnippetIndexView(WagtailTestUtils, TestCase):
@ -2093,13 +2095,13 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -2237,13 +2239,13 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -2412,13 +2414,13 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -2589,7 +2591,7 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
# Should still show the active expire_at in the live object
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -2603,13 +2605,13 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(new_expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
html=True,
count=1,
)
@ -2703,7 +2705,7 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
# override the existing expire_at when it goes live
self.assertNotContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
)
@ -2715,13 +2717,13 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(new_expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
html=True,
count=1,
)
@ -2817,7 +2819,7 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
# unpublished (expired) -> published (scheduled) -> unpublished (expired again)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(expire_at)}',
html=True,
count=1,
)
@ -2830,13 +2832,13 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(go_live_at)}',
html=True,
count=1,
)
self.assertContains(
response,
f'<span class="w-text-grey-600">Expiry:</span> {rendered_timestamp(new_expire_at)}',
f'<span class="w-text-grey-600">Expiry:</span> {render_timestamp(new_expire_at)}',
html=True,
count=1,
)
@ -2903,7 +2905,7 @@ class TestScheduledForPublishLock(BaseTestSnippetEditView):
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(self.go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(self.go_live_at)}',
html=True,
count=1,
)
@ -2970,7 +2972,7 @@ class TestScheduledForPublishLock(BaseTestSnippetEditView):
self.assertContains(
response,
f'<span class="w-text-grey-600">Go-live:</span> {rendered_timestamp(self.go_live_at)}',
f'<span class="w-text-grey-600">Go-live:</span> {render_timestamp(self.go_live_at)}',
html=True,
count=1,
)
@ -3919,10 +3921,16 @@ class TestSnippetRevisions(WagtailTestUtils, TestCase):
response = self.get()
self.assertEqual(response.status_code, 200)
if settings.USE_TZ:
# the default timezone is "Asia/Tokyo", so we expect UTC +9
expected_date_string = "May 10, 2022, 8 p.m."
else:
expected_date_string = "May 10, 2022, 11 a.m."
# Message should be shown
self.assertContains(
response,
"You are viewing a previous version of this Revisable model from <b>10 May 2022 11:00</b> by",
f"You are viewing a previous version of this Revisable model from <b>{expected_date_string}</b> by",
count=1,
)

Wyświetl plik

@ -1,8 +1,6 @@
import datetime
from django.conf import settings
from django.utils import timezone
from django.utils.formats import date_format
def submittable_timestamp(timestamp):
@ -19,16 +17,6 @@ def submittable_timestamp(timestamp):
return timestamp.strftime("%Y-%m-%d %H:%M")
def rendered_timestamp(timestamp):
"""
Helper function to format a possibly-timezone-aware datetime into the format
used by Django (e.g. in templates).
"""
if timezone.is_aware(timestamp):
timestamp = timezone.localtime(timestamp)
return date_format(timestamp, settings.DATETIME_FORMAT)
def local_datetime(*args):
dt = datetime.datetime(*args)
return timezone.make_aware(dt)

Wyświetl plik

@ -1,8 +1,10 @@
from datetime import datetime, timedelta
import datetime
import json
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.test import TestCase
from django.utils import timezone
from freezegun import freeze_time
@ -155,9 +157,12 @@ class TestAuditLog(TestCase):
)
def test_revision_schedule_publish(self):
go_live_at = datetime.now() + timedelta(days=1)
go_live_at = datetime.datetime.now() + datetime.timedelta(days=1)
if settings.USE_TZ:
go_live_at = timezone.make_aware(go_live_at)
expected_go_live_at = timezone.localtime(go_live_at, datetime.timezone.utc)
else:
expected_go_live_at = go_live_at
self.home_page.go_live_at = go_live_at
# with no live revision
@ -169,7 +174,8 @@ class TestAuditLog(TestCase):
self.assertEqual(log_entries[0].data["revision"]["id"], revision.id)
self.assertEqual(
log_entries[0].data["revision"]["go_live_at"],
go_live_at.strftime("%d %b %Y %H:%M"),
# skip double quotes
json.dumps(expected_go_live_at, cls=DjangoJSONEncoder)[1:-1],
)
def test_revision_schedule_revert(self):
@ -178,10 +184,12 @@ class TestAuditLog(TestCase):
if settings.USE_TZ:
self.home_page.go_live_at = timezone.make_aware(
datetime.now() + timedelta(days=1)
datetime.datetime.now() + datetime.timedelta(days=1)
)
else:
self.home_page.go_live_at = datetime.now() + timedelta(days=1)
self.home_page.go_live_at = datetime.datetime.now() + datetime.timedelta(
days=1
)
schedule_revision = self.home_page.save_revision(
log_action=True, previous_revision=revision2
@ -197,9 +205,12 @@ class TestAuditLog(TestCase):
)
def test_revision_cancel_schedule(self):
go_live_at = datetime.now() + timedelta(days=1)
go_live_at = datetime.datetime.now() + datetime.timedelta(days=1)
if settings.USE_TZ:
go_live_at = timezone.make_aware(go_live_at)
expected_go_live_at = timezone.localtime(go_live_at, datetime.timezone.utc)
else:
expected_go_live_at = go_live_at
self.home_page.go_live_at = go_live_at
revision = self.home_page.save_revision()
revision.publish()
@ -212,7 +223,8 @@ class TestAuditLog(TestCase):
self.assertEqual(log_entries[0].data["revision"]["id"], revision.id)
self.assertEqual(
log_entries[0].data["revision"]["go_live_at"],
go_live_at.strftime("%d %b %Y %H:%M"),
# skip double quotes
json.dumps(expected_go_live_at, cls=DjangoJSONEncoder)[1:-1],
)
# The home_page was live already and we've only cancelled the publication of the above revision.
self.assertTrue(log_entries[0].data["revision"]["has_live_version"])

Wyświetl plik

@ -3611,19 +3611,31 @@ class TestGetLock(TestCase):
christmas_event = EventPage.objects.get(url_path="/home/events/christmas/")
christmas_event.locked = True
christmas_event.locked_by = moderator
christmas_event.locked_at = datetime.datetime(2022, 7, 29, 12, 19, 0)
if settings.USE_TZ:
christmas_event.locked_at = datetime.datetime(
2022, 7, 29, 12, 19, 0, tzinfo=datetime.timezone.utc
)
else:
christmas_event.locked_at = datetime.datetime(2022, 7, 29, 12, 19, 0)
lock = christmas_event.get_lock()
self.assertIsInstance(lock, BasicLock)
self.assertTrue(lock.for_user(christmas_event.owner))
self.assertFalse(lock.for_user(moderator))
if settings.USE_TZ:
# the default timezone is "Asia/Tokyo", so we expect UTC +9
expected_date_string = "July 29, 2022, 9:19 p.m."
else:
expected_date_string = "July 29, 2022, 12:19 p.m."
self.assertEqual(
lock.get_message(christmas_event.owner),
f"<b>'Christmas' was locked</b> by <b>{str(moderator)}</b> on <b>29 Jul 2022 12:19</b>.",
f"<b>'Christmas' was locked</b> by <b>{str(moderator)}</b> on <b>{expected_date_string}</b>.",
)
self.assertEqual(
lock.get_message(moderator),
"<b>'Christmas' was locked</b> by <b>you</b> on <b>29 Jul 2022 12:19</b>.",
f"<b>'Christmas' was locked</b> by <b>you</b> on <b>{expected_date_string}</b>.",
)
def test_when_locked_without_locked_at(self):
@ -3650,19 +3662,31 @@ class TestGetLock(TestCase):
christmas_event = EventPage.objects.get(url_path="/home/events/christmas/")
christmas_event.locked = True
christmas_event.locked_by = moderator
christmas_event.locked_at = datetime.datetime(2022, 7, 29, 12, 19, 0)
if settings.USE_TZ:
christmas_event.locked_at = datetime.datetime(
2022, 7, 29, 12, 19, 0, tzinfo=datetime.timezone.utc
)
else:
christmas_event.locked_at = datetime.datetime(2022, 7, 29, 12, 19, 0)
lock = christmas_event.get_lock()
self.assertIsInstance(lock, BasicLock)
self.assertTrue(lock.for_user(christmas_event.owner))
self.assertTrue(lock.for_user(moderator))
if settings.USE_TZ:
# the default timezone is "Asia/Tokyo", so we expect UTC +9
expected_date_string = "July 29, 2022, 9:19 p.m."
else:
expected_date_string = "July 29, 2022, 12:19 p.m."
self.assertEqual(
lock.get_message(christmas_event.owner),
f"<b>'Christmas' was locked</b> by <b>{str(moderator)}</b> on <b>29 Jul 2022 12:19</b>.",
f"<b>'Christmas' was locked</b> by <b>{str(moderator)}</b> on <b>{expected_date_string}</b>.",
)
self.assertEqual(
lock.get_message(moderator),
"<b>'Christmas' was locked</b> by <b>you</b> on <b>29 Jul 2022 12:19</b>.",
f"<b>'Christmas' was locked</b> by <b>you</b> on <b>{expected_date_string}</b>.",
)
@override_settings(WAGTAILADMIN_GLOBAL_PAGE_EDIT_LOCK=True)
@ -3672,7 +3696,12 @@ class TestGetLock(TestCase):
christmas_event = EventPage.objects.get(url_path="/home/events/christmas/")
christmas_event.locked = True
christmas_event.locked_by = moderator
christmas_event.locked_at = datetime.datetime(2022, 7, 29, 12, 19, 0)
if settings.USE_TZ:
christmas_event.locked_at = datetime.datetime(
2022, 7, 29, 12, 19, 0, tzinfo=datetime.timezone.utc
)
else:
christmas_event.locked_at = datetime.datetime(2022, 7, 29, 12, 19, 0)
lock = christmas_event.get_lock()
self.assertIsInstance(lock, BasicLock)
@ -3685,13 +3714,19 @@ class TestGetLock(TestCase):
self.assertTrue(lock.for_user(christmas_event.owner))
self.assertTrue(lock.for_user(moderator))
if settings.USE_TZ:
# the default timezone is "Asia/Tokyo", so we expect UTC +9
expected_date_string = "July 29, 2022, 9:19 p.m."
else:
expected_date_string = "July 29, 2022, 12:19 p.m."
self.assertEqual(
lock.get_message(christmas_event.owner),
f"<b>'Christmas' was locked</b> by <b>{str(moderator)}</b> on <b>29 Jul 2022 12:19</b>.",
f"<b>'Christmas' was locked</b> by <b>{str(moderator)}</b> on <b>{expected_date_string}</b>.",
)
self.assertEqual(
lock.get_message(moderator),
"<b>'Christmas' was locked</b> by <b>you</b> on <b>29 Jul 2022 12:19</b>.",
f"<b>'Christmas' was locked</b> by <b>you</b> on <b>{expected_date_string}</b>.",
)
def test_when_locked_by_workflow(self):
@ -3730,23 +3765,29 @@ class TestGetLock(TestCase):
def test_when_scheduled_for_publish(self):
christmas_event = EventPage.objects.get(url_path="/home/events/christmas/")
christmas_event.go_live_at = datetime.datetime(2030, 7, 29, 16, 32, 0)
if settings.USE_TZ:
christmas_event.go_live_at = datetime.datetime(
2030, 7, 29, 16, 32, 0, tzinfo=datetime.timezone.utc
)
else:
christmas_event.go_live_at = datetime.datetime(2030, 7, 29, 16, 32, 0)
rvn = christmas_event.save_revision()
rvn.publish()
lock = christmas_event.get_lock()
self.assertIsInstance(lock, ScheduledForPublishLock)
self.assertTrue(lock.for_user(christmas_event.owner))
if settings.USE_TZ:
self.assertEqual(
lock.get_message(christmas_event.owner),
"Page 'Christmas' is locked and has been scheduled to go live at 29 Jul 2030 07:32",
)
# the default timezone is "Asia/Tokyo", so we expect UTC +9
expected_date_string = "July 30, 2030, 1:32 a.m."
else:
self.assertEqual(
lock.get_message(christmas_event.owner),
"Page 'Christmas' is locked and has been scheduled to go live at 29 Jul 2030 16:32",
)
expected_date_string = "July 29, 2030, 4:32 p.m."
self.assertEqual(
lock.get_message(christmas_event.owner),
f"Page 'Christmas' is locked and has been scheduled to go live at {expected_date_string}",
)
# Not even superusers can break this lock
# This is because it shouldn't be possible to create a separate draft from what is scheduled to be published

Wyświetl plik

@ -0,0 +1,43 @@
import datetime
from django.conf import settings
from django.utils import formats, timezone
from django.utils.dateparse import parse_datetime
def ensure_utc(value):
"""
Similar to how django-modelcluster stores the revision's data and similar to how
django stores dates in the database, this converts the date to UTC if required.
"""
# https://github.com/wagtail/django-modelcluster/blob/8666f16eaf23ca98afc160b0a4729864411c0563/modelcluster/models.py#L21-L28
if settings.USE_TZ:
if timezone.is_naive(value):
default_timezone = timezone.get_default_timezone()
value = timezone.make_aware(value, default_timezone).astimezone(
datetime.timezone.utc
)
else:
# convert to UTC
value = timezone.localtime(value, datetime.timezone.utc)
return value
def parse_datetime_localized(date_string):
"""
Uses Django's parse_datetime(), but ensures to return an aware datetime.
"""
dt = parse_datetime(date_string)
if settings.USE_TZ and timezone.is_naive(dt):
dt = timezone.make_aware(dt, timezone=timezone.get_default_timezone())
return dt
def render_timestamp(timestamp):
"""
Helper function to format a possibly-timezone-aware datetime into the format
used by Django (e.g. in templates).
"""
if timezone.is_aware(timestamp):
timestamp = timezone.localtime(timestamp)
return formats.date_format(timestamp, "DATETIME_FORMAT")

Wyświetl plik

@ -12,6 +12,7 @@ from wagtail.coreutils import get_content_languages
from wagtail.log_actions import LogFormatter
from wagtail.models import ModelLogEntry, Page, PageLogEntry, PageViewRestriction
from wagtail.rich_text.pages import PageLinkHandler
from wagtail.utils.timestamps import parse_datetime_localized, render_timestamp
def require_wagtail_login(next):
@ -152,7 +153,11 @@ def register_core_log_actions(actions):
"Reverted to previous revision with id %(revision_id)s from %(created_at)s"
) % {
"revision_id": log_entry.data["revision"]["id"],
"created_at": log_entry.data["revision"]["created"],
"created_at": render_timestamp(
parse_datetime_localized(
log_entry.data["revision"]["created"],
)
),
}
except KeyError:
return _("Reverted to previous revision")
@ -245,12 +250,24 @@ def register_core_log_actions(actions):
"Revision %(revision_id)s from %(created_at)s scheduled for publishing at %(go_live_at)s."
) % {
"revision_id": log_entry.data["revision"]["id"],
"created_at": log_entry.data["revision"]["created"],
"go_live_at": log_entry.data["revision"]["go_live_at"],
"created_at": render_timestamp(
parse_datetime_localized(
log_entry.data["revision"]["created"],
)
),
"go_live_at": render_timestamp(
parse_datetime_localized(
log_entry.data["revision"]["go_live_at"],
)
),
}
else:
return _("Page scheduled for publishing at %(go_live_at)s") % {
"go_live_at": log_entry.data["revision"]["go_live_at"],
"go_live_at": render_timestamp(
parse_datetime_localized(
log_entry.data["revision"]["go_live_at"],
)
),
}
except KeyError:
return _("Page scheduled for publishing")
@ -266,12 +283,28 @@ def register_core_log_actions(actions):
"Revision %(revision_id)s from %(created_at)s unscheduled from publishing at %(go_live_at)s."
) % {
"revision_id": log_entry.data["revision"]["id"],
"created_at": log_entry.data["revision"]["created"],
"go_live_at": log_entry.data["revision"]["go_live_at"],
"created_at": render_timestamp(
parse_datetime_localized(
log_entry.data["revision"]["created"],
)
),
"go_live_at": render_timestamp(
parse_datetime_localized(
log_entry.data["revision"]["go_live_at"],
)
)
if log_entry.data["revision"]["go_live_at"]
else None,
}
else:
return _("Page unscheduled for publishing at %(go_live_at)s") % {
"go_live_at": log_entry.data["revision"]["go_live_at"],
"go_live_at": render_timestamp(
parse_datetime_localized(
log_entry.data["revision"]["go_live_at"],
)
)
if log_entry.data["revision"]["go_live_at"]
else None,
}
except KeyError:
return _("Page unscheduled from publishing")