From e559c10d2cd578f733c210ea32ec263beebb1fc8 Mon Sep 17 00:00:00 2001 From: Thomas Buchberger Date: Sun, 22 Oct 2017 18:14:21 +0200 Subject: [PATCH] Fix datetime deserialization for timezone aware fields --- CHANGES.rst | 5 +++ setup.py | 1 + src/plone/restapi/deserializer/dxfields.py | 40 ++++++++++++++++--- src/plone/restapi/tests/dxtypes.py | 5 +++ .../tests/test_dxfield_deserializer.py | 25 ++++++++++++ 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1e25779aae..6bfd814fe6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,6 +14,11 @@ Changelog a soft dependency. It is included in Plone >= 5.1. [tomgross] +- Fix datetime deserialization for timezone aware fields. + This fixes https://github.com/plone/plone.restapi/issues/253 + [buchi] + + 1.0a21 (2017-09-23) ------------------- diff --git a/setup.py b/setup.py index 82a83bf1a0..1991dfcced 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ 'setuptools', 'plone.rest >= 1.0a6', # json renderer moved to plone.restapi 'PyJWT', + 'pytz', ], extras_require={'test': [ 'Products.Archetypes', diff --git a/src/plone/restapi/deserializer/dxfields.py b/src/plone/restapi/deserializer/dxfields.py index 3af62dbac6..fcd6b45688 100644 --- a/src/plone/restapi/deserializer/dxfields.py +++ b/src/plone/restapi/deserializer/dxfields.py @@ -8,8 +8,12 @@ from plone.namedfile.interfaces import INamedField from plone.restapi.interfaces import IFieldDeserializer from plone.restapi.services.content.tus import TUSUpload +from pytz import timezone +from pytz import utc +from z3c.form.interfaces import IDataManager from zope.component import adapter from zope.component import getMultiAdapter +from zope.component import queryMultiAdapter from zope.interface import implementer from zope.publisher.interfaces.browser import IBrowserRequest from zope.schema.interfaces import ICollection @@ -17,9 +21,9 @@ from zope.schema.interfaces import IDict from zope.schema.interfaces import IField from zope.schema.interfaces import IFromUnicode +from zope.schema.interfaces import ITextLine from zope.schema.interfaces import ITime from zope.schema.interfaces import ITimedelta -from zope.schema.interfaces import ITextLine @implementer(IFieldDeserializer) @@ -62,14 +66,40 @@ def __call__(self, value): 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 + + # Parse ISO 8601 string with Zope's DateTime module try: - # Parse ISO 8601 string with Zope's DateTime module - # and convert to a timezone naive datetime in local time - value = DateTime(value).toZone(DateTime().localZone()).asdatetime( - ).replace(tzinfo=None) + dt = DateTime(value).asdatetime() except (SyntaxError, DateTimeError) as e: raise ValueError(e.message) + # Convert to TZ aware in UTC + if dt.tzinfo is not None: + dt = dt.astimezone(utc) + 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)) + else: + value = utc.normalize(dt.astimezone(utc)).replace(tzinfo=None) + self.field.validate(value) return value diff --git a/src/plone/restapi/tests/dxtypes.py b/src/plone/restapi/tests/dxtypes.py index ae90b312ff..e234236693 100644 --- a/src/plone/restapi/tests/dxtypes.py +++ b/src/plone/restapi/tests/dxtypes.py @@ -11,6 +11,7 @@ from plone.dexterity.content import Item from plone.namedfile import field as namedfile from plone.supermodel import model +from pytz import timezone from z3c.relationfield.schema import RelationChoice from z3c.relationfield.schema import RelationList from zope import schema @@ -47,6 +48,10 @@ class IDXTestDocumentSchema(model.Schema): test_choice_field = schema.Choice(values=[u'foo', u'bar'], required=False) test_date_field = schema.Date(required=False) test_datetime_field = schema.Datetime(required=False) + test_datetime_tz_field = schema.Datetime( + required=False, + defaultFactory=lambda: timezone("Europe/Zurich").localize( + datetime(2017, 10, 31, 10, 0))) test_decimal_field = schema.Decimal(required=False) test_dict_field = schema.Dict(required=False) test_float_field = schema.Float(required=False) diff --git a/src/plone/restapi/tests/test_dxfield_deserializer.py b/src/plone/restapi/tests/test_dxfield_deserializer.py index bf32186745..376a7f9a19 100644 --- a/src/plone/restapi/tests/test_dxfield_deserializer.py +++ b/src/plone/restapi/tests/test_dxfield_deserializer.py @@ -9,6 +9,7 @@ from plone.dexterity.utils import iterSchemata from plone.restapi.interfaces import IFieldDeserializer from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING +from pytz import timezone from zope.component import getMultiAdapter from zope.schema.interfaces import ValidationError @@ -86,6 +87,30 @@ def test_datetime_deserialization_handles_timezone(self): u'2015-12-20T10:39:54.361+01') self.assertEqual(datetime(2015, 12, 20, 9, 39, 54, 361000), value) + def test_datetime_with_tz_deserialization_keeps_timezone(self): + value = self.deserialize('test_datetime_tz_field', + u'2015-12-20T10:39:54.361+01') + self.assertEqual(timezone("Europe/Zurich").localize( + datetime(2015, 12, 20, 10, 39, 54, 361000)), value) + + def test_datetime_with_tz_deserialization_converts_timezone(self): + value = self.deserialize('test_datetime_tz_field', + u'2015-12-20T10:39:54.361-04') + self.assertEqual(timezone("Europe/Zurich").localize( + datetime(2015, 12, 20, 15, 39, 54, 361000)), value) + + def test_datetime_with_tz_deserialization_adds_timezone(self): + value = self.deserialize('test_datetime_tz_field', + u'2015-12-20T10:39:54.361') + self.assertEqual(timezone("Europe/Zurich").localize( + datetime(2015, 12, 20, 11, 39, 54, 361000)), value) + + def test_datetime_with_tz_deserialization_handles_dst(self): + value = self.deserialize('test_datetime_tz_field', + u'2015-05-20T10:39:54.361+02') + self.assertEqual(timezone("Europe/Zurich").localize( + datetime(2015, 05, 20, 10, 39, 54, 361000)), value) + def test_decimal_deserialization_returns_decimal(self): value = self.deserialize('test_decimal_field', u'1.1') self.assertTrue(isinstance(value, Decimal), 'Not a ')