Skip to content

Commit

Permalink
Handle timezone in publication fields (effective and expires) (#1192)
Browse files Browse the repository at this point in the history
* Fix deserializer/serializer to handle timezone in IPublication fields

* fix code-style

* add changelog

* Make it work

* Fix DateTime patch

---------

Co-authored-by: Steve Piercy <web@stevepiercy.com>
Co-authored-by: David Glick <david@glicksoftware.com>
  • Loading branch information
3 people authored Feb 13, 2025
1 parent 90931f4 commit 6e2ab6b
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 21 deletions.
1 change: 1 addition & 0 deletions news/1192.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Save effective and expires date into Plone with right hours (according to current timezone) [cekk]
44 changes: 24 additions & 20 deletions src/plone/restapi/deserializer/dxfields.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import timedelta
from decimal import Decimal
from plone.app.contenttypes.interfaces import ILink
from plone.app.dexterity.behaviors.metadata import IPublication
from plone.app.textfield.interfaces import IRichText
from plone.app.textfield.value import RichTextValue
from plone.dexterity.interfaces import IDexterityContent
Expand Down Expand Up @@ -30,6 +31,7 @@

import codecs
import dateutil

import html as html_parser


Expand Down Expand Up @@ -89,21 +91,6 @@ def __call__(self, value):
@adapter(IDatetime, IDexterityContent, IBrowserRequest)
class DatetimeFieldDeserializer(DefaultFieldDeserializer):
def __call__(self, value):
# Datetime fields may contain timezone naive or timezone aware
# objects. Unfortunately the zope.schema.Datetime field does not
# contain any information if the field value should be timezone naive
# or timezone aware. While some fields (start, end) store timezone
# aware objects others (effective, expires) store timezone naive
# objects.
# We try to guess the correct deserialization from the current field
# value.
dm = queryMultiAdapter((self.context, self.field), IDataManager)
current = dm.get()
if current is not None:
tzinfo = current.tzinfo
else:
tzinfo = None

# This happens when a 'null' is posted for a non-required field.
if value is None:
self.field.validate(value)
Expand All @@ -121,12 +108,29 @@ def __call__(self, value):
else:
dt = utc.localize(dt)

# Convert to local TZ aware or naive UTC
if tzinfo is not None:
tz = timezone(tzinfo.zone)
value = tz.normalize(dt.astimezone(tz))
# Datetime fields may contain timezone naive or timezone aware
# objects. Unfortunately the zope.schema.Datetime field does not
# contain any information if the field value should be timezone naive
# or timezone aware. While some fields (start, end) store timezone
# aware objects others (effective, expires) store timezone naive
# objects.
# We try to guess the correct deserialization from the current field
# value.
if self.field.interface == IPublication:
# The IPublication adapter is a special case that expects
# a timezone-naive local datetime
value = dt.astimezone().replace(tzinfo=None)
else:
value = utc.normalize(dt.astimezone(utc)).replace(tzinfo=None)
# Otherwise let's check what is currently stored.
dm = queryMultiAdapter((self.context, self.field), IDataManager)
current = dm.get()
if current is not None:
# Timezone-aware. Convert to the same timezone.
tz = timezone(current.tzinfo.zone)
value = tz.normalize(dt.astimezone(tz))
else:
# Timezone-naive. Convert to UTC and remove the tzinfo.
value = utc.normalize(dt.astimezone(utc)).replace(tzinfo=None)

self.field.validate(value)
return value
Expand Down
1 change: 1 addition & 0 deletions src/plone/restapi/serializer/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<adapter factory=".dxfields.DefaultPrimaryFieldTarget" />
<adapter factory=".dxfields.PrimaryFileFieldTarget" />
<adapter factory=".dxfields.TextLineFieldSerializer" />
<adapter factory=".dxfields.DateTimeFieldSerializer" />

<adapter factory=".blocks.BlocksJSONFieldSerializer" />
<subscriber
Expand Down
5 changes: 4 additions & 1 deletion src/plone/restapi/serializer/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@

def datetimelike_to_iso(value):
if isinstance(value, DateTime):
value = value.asdatetime()
if value.timezoneNaive():
value = value.asdatetime()
else:
value = pytz.timezone("UTC").localize(value.utcdatetime())

if getattr(value, "tzinfo", None):
# timezone aware date/time objects are converted to UTC first.
Expand Down
16 changes: 16 additions & 0 deletions src/plone/restapi/serializer/dxfields.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from AccessControl import getSecurityManager
from plone.app.contenttypes.interfaces import ILink
from plone.app.contenttypes.utils import replace_link_variables_by_paths
from plone.app.dexterity.behaviors.metadata import IPublication
from plone.app.textfield.interfaces import IRichText
from plone.dexterity.interfaces import IDexterityContent
from plone.namedfile.interfaces import INamedFileField
Expand All @@ -18,6 +19,7 @@
from zope.interface import Interface
from zope.schema.interfaces import IChoice
from zope.schema.interfaces import ICollection
from zope.schema.interfaces import IDatetime
from zope.schema.interfaces import IField
from zope.schema.interfaces import ITextLine
from zope.schema.interfaces import IVocabularyTokenized
Expand Down Expand Up @@ -206,3 +208,17 @@ def __call__(self):
return "/".join(
(self.context.absolute_url(), "@@download", self.field.__name__)
)


@adapter(IDatetime, IDexterityContent, Interface)
@implementer(IFieldSerializer)
class DateTimeFieldSerializer(DefaultFieldSerializer):
def get_value(self, default=None):
value = super(DateTimeFieldSerializer, self).get_value(default=default)
if value and self.field.interface == IPublication:
# We want the dates with full tz infos
# default value is taken from
# plone.app.dexterity.behaviors.metadata.Publication that escape
# timezone
return getattr(self.context, self.field.__name__)()
return value
86 changes: 86 additions & 0 deletions src/plone/restapi/tests/test_dxfield_publication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
from DateTime import DateTime
from plone.registry.interfaces import IRegistry
from plone.restapi.interfaces import IDeserializeFromJson
from plone.restapi.interfaces import ISerializeToJson
from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING
from transaction import commit
from zope.component import getMultiAdapter
from zope.component import getUtility

import unittest
import os
import time


class TestPublicationFields(unittest.TestCase):

layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING

def setUp(self):

self.portal = self.layer["portal"]
self.request = self.layer["request"]

tz = "Europe/Rome"
os.environ["TZ"] = tz
time.tzset()

# Patch DateTime's timezone for deterministic behavior.
self.DT_orig_localZone = DateTime.localZone
self.DT_orig_calcTimezoneName = DateTime._calcTimezoneName
DateTime.localZone = lambda cls=None, ltm=None: tz
DateTime._calcTimezoneName = lambda self, x, ms: tz

from plone.dexterity import content

content.FLOOR_DATE = DateTime(1970, 0)
content.CEILING_DATE = DateTime(2500, 0)
self._orig_content_zone = content._zone
content._zone = "GMT+2"

registry = getUtility(IRegistry)
registry["plone.portal_timezone"] = tz
registry["plone.available_timezones"] = [tz]

self.app = self.layer["app"]
self.portal = self.layer["portal"]

commit()

def tearDown(self):
os.environ["TZ"] = "UTC"
time.tzset()

from DateTime import DateTime

DateTime.localZone = self.DT_orig_localZone
DateTime._calcTimezoneName = self.DT_orig_calcTimezoneName

from plone.dexterity import content

content._zone = self._orig_content_zone
content.FLOOR_DATE = DateTime(1970, 0)
content.CEILING_DATE = DateTime(2500, 0)

registry = getUtility(IRegistry)
registry["plone.portal_timezone"] = "UTC"
registry["plone.available_timezones"] = ["UTC"]

def test_effective_date_deserialization_localized(self):
self.portal.invokeFactory("Document", id="doc-test", title="Test Document")
doc = self.portal["doc-test"]
deserializer = getMultiAdapter(
(self.portal["doc-test"], self.request), IDeserializeFromJson
)
deserializer(data={"effective": "2015-05-20T10:39:54.361+00"})
self.assertEqual(str(doc.effective_date), "2015/05/20 12:39:00 Europe/Rome")

def test_effective_date_serialization_localized(self):
self.portal.invokeFactory("Document", id="doc-test", title="Test Document")
doc = self.portal["doc-test"]
doc.effective_date = DateTime("2015/05/20 12:39:00 Europe/Rome")

serializer = getMultiAdapter((doc, self.request), ISerializeToJson)
data = serializer()
self.assertEqual(data["effective"], "2015-05-20T10:39:00+00:00")

0 comments on commit 6e2ab6b

Please sign in to comment.