From 338ab4e4e20cfb9d51df6c524f80d682b7673550 Mon Sep 17 00:00:00 2001 From: David Glick Date: Fri, 29 May 2026 12:47:45 +0200 Subject: [PATCH 1/5] Handle start_timezone and end_timezone during (de)serialization --- Makefile | 4 +- news/1878.feature | 3 ++ src/plone/restapi/deserializer/dxcontent.py | 5 +++ src/plone/restapi/deserializer/dxfields.py | 12 ++++++ src/plone/restapi/serializer/dxcontent.py | 7 ++++ src/plone/restapi/serializer/utils.py | 13 +++++++ .../restapi/tests/http-examples/event.resp | 2 + .../tests/test_dxcontent_serializer.py | 14 +++++++ .../tests/test_dxfield_deserializer.py | 37 ++++++++++++++----- 9 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 news/1878.feature diff --git a/Makefile b/Makefile index c77befa137..d5e7beb114 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,8 @@ BUILDDIR = ../_build/ ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(SPHINXOPTS) . VALEFILES := $(shell find $(DOCS_DIR) -type f -name "*.md" -print) +TEST_ARGS ?= "" + all: help # Add the following 'help' target to your Makefile @@ -119,7 +121,7 @@ check: $(BIN_FOLDER)/tox ## Check and fix code base according to Plone standards .PHONY: test test: $(BIN_FOLDER)/zope-testrunner ## Run tests - zope_i18n_compile_mo_files=True $(BIN_FOLDER)/zope-testrunner --all --test-path=src -s plone.restapi + zope_i18n_compile_mo_files=True $(BIN_FOLDER)/zope-testrunner --all --test-path=src -s plone.restapi $(TEST_ARGS) .PHONY: i18n i18n: $(BIN_FOLDER)/update_restapi_locales ## Update locales diff --git a/news/1878.feature b/news/1878.feature new file mode 100644 index 0000000000..4e39cee80e --- /dev/null +++ b/news/1878.feature @@ -0,0 +1,3 @@ +Add `start_timezone` and `end_timezone` to serialized Events. +During deserialization, store values in these timezones if specified. +@davisagli diff --git a/src/plone/restapi/deserializer/dxcontent.py b/src/plone/restapi/deserializer/dxcontent.py index 2be6564cc4..5ce183a994 100644 --- a/src/plone/restapi/deserializer/dxcontent.py +++ b/src/plone/restapi/deserializer/dxcontent.py @@ -129,6 +129,11 @@ def get_schema_data(self, data, validate_all, create=False): if deserializer is None: continue + if name == "start" and "start_timezone" in data: + deserializer.requested_timezone = data["start_timezone"] + elif name == "end" and "end_timezone" in data: + deserializer.requested_timezone = data["end_timezone"] + try: value = deserializer(data[name]) except ValueError as e: diff --git a/src/plone/restapi/deserializer/dxfields.py b/src/plone/restapi/deserializer/dxfields.py index 47ae4dfba7..6dcb9f978d 100644 --- a/src/plone/restapi/deserializer/dxfields.py +++ b/src/plone/restapi/deserializer/dxfields.py @@ -10,6 +10,7 @@ from plone.restapi.interfaces import IFieldDeserializer from plone.restapi.services.content.tus import TUSUpload from pytz import timezone +from pytz import UnknownTimeZoneError from pytz import utc from z3c.form.interfaces import IDataManager from zope.component import adapter @@ -89,6 +90,9 @@ def __call__(self, value): @implementer(IFieldDeserializer) @adapter(IDatetime, IDexterityContent, IBrowserRequest) class DatetimeFieldDeserializer(DefaultFieldDeserializer): + + requested_timezone = None + def __call__(self, value): # This happens when a 'null' is posted for a non-required field. if value is None: @@ -119,6 +123,14 @@ def __call__(self, value): # The IPublication adapter is a special case that expects # a timezone-naive local datetime value = dt.astimezone().replace(tzinfo=None) + elif self.requested_timezone is not None: + # Use the requested timezone if set + try: + tz = timezone(self.requested_timezone) + except UnknownTimeZoneError: + raise ValueError(f"Unknown timezone: {self.requested_timezone}") + else: + value = tz.normalize(dt.astimezone(tz)) else: # Otherwise let's check what is currently stored. dm = queryMultiAdapter((self.context, self.field), IDataManager) diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index 6ce31b80b7..e74f0772d8 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -1,6 +1,7 @@ from Acquisition import aq_inner from Acquisition import aq_parent from plone.app.contenttypes.interfaces import ILink +from plone.app.event.dx.behaviors import IEventBasic from plone.autoform.interfaces import READ_PERMISSIONS_KEY from plone.dexterity.interfaces import IDexterityContainer from plone.dexterity.interfaces import IDexterityContent @@ -19,6 +20,7 @@ from plone.restapi.serializer.nextprev import NextPrevious from plone.restapi.serializer.schema import _check_permission from plone.restapi.serializer.utils import get_portal_type_title +from plone.restapi.serializer.utils import get_timezone_name from plone.restapi.services.locking import lock_info from plone.rfc822.interfaces import IPrimaryFieldInfo from plone.supermodel.utils import mergedTaggedValueDict @@ -133,6 +135,11 @@ def __call__(self, version=None, include_items=True, include_expansion=True): ) result.update(schema_serializer()) + # Add event timezones + if IEventBasic.providedBy(self.context): + result["start_timezone"] = get_timezone_name(self.context.start) + result["end_timezone"] = get_timezone_name(self.context.end) + target_url = getMultiAdapter( (self.context, self.request), IObjectPrimaryFieldTarget )() diff --git a/src/plone/restapi/serializer/utils.py b/src/plone/restapi/serializer/utils.py index 39cc69cf7c..0749c7988a 100644 --- a/src/plone/restapi/serializer/utils.py +++ b/src/plone/restapi/serializer/utils.py @@ -1,3 +1,4 @@ +from datetime import datetime from plone.app.uuid.utils import uuidToCatalogBrain from plone.dexterity.schema import lookup_fti from plone.restapi.interfaces import IObjectPrimaryFieldTarget @@ -5,7 +6,9 @@ from zope.globalrequest import getRequest from zope.i18n import translate +import pytz import re +import zoneinfo RESOLVEUID_RE = re.compile("^(?:|.*/)resolve[Uu]id/([^/#]*)?(.*)?$") @@ -52,3 +55,13 @@ def get_portal_type_title(portal_type): if request: return translate(getattr(fti, "Title", lambda: portal_type)(), context=request) return getattr(fti, "Title", lambda: portal_type)() + + +def get_timezone_name(dt: datetime) -> str | None: + tzinfo = dt.tzinfo + if isinstance(tzinfo, zoneinfo.ZoneInfo): + return tzinfo.key + elif isinstance(tzinfo, pytz.tzinfo.BaseTzInfo) and tzinfo.zone: + return tzinfo.zone + elif tzinfo is not None: + return tzinfo.tzname(dt) diff --git a/src/plone/restapi/tests/http-examples/event.resp b/src/plone/restapi/tests/http-examples/event.resp index 7cf0051a36..40c2013fed 100644 --- a/src/plone/restapi/tests/http-examples/event.resp +++ b/src/plone/restapi/tests/http-examples/event.resp @@ -45,6 +45,7 @@ Content-Type: application/json "description": "This is an event", "effective": null, "end": "2013-01-01T12:00:00+00:00", + "end_timezone": "UTC", "event_url": null, "exclude_from_nav": false, "expires": null, @@ -73,6 +74,7 @@ Content-Type: application/json "review_state": "private", "rights": "", "start": "2013-01-01T10:00:00+00:00", + "start_timezone": "UTC", "subjects": [], "sync_uid": null, "text": null, diff --git a/src/plone/restapi/tests/test_dxcontent_serializer.py b/src/plone/restapi/tests/test_dxcontent_serializer.py index c2d23079a7..731ebcdbeb 100644 --- a/src/plone/restapi/tests/test_dxcontent_serializer.py +++ b/src/plone/restapi/tests/test_dxcontent_serializer.py @@ -38,6 +38,7 @@ import json import unittest +import zoneinfo HAS_PLONE_6 = getattr( import_module("Products.CMFPlone.factory"), "PLONE60MARKER", False @@ -802,6 +803,19 @@ def test_get_layout_for_siteroot(self): self.assertIn("layout", obj) self.assertEqual(current_layout, obj["layout"]) + def test_serializer_includes_event_timezones(self): + tz = zoneinfo.ZoneInfo("America/Los_Angeles") + self.portal.invokeFactory( + "Event", + id="event1", + start=datetime(2026, 5, 29, 0, tzinfo=tz), + end=datetime(2026, 5, 29, 1, tzinfo=tz), + ) + event = self.portal.event1 + obj = self.serialize(event) + self.assertEqual(obj["start_timezone"], "America/Los_Angeles") + self.assertEqual(obj["end_timezone"], "America/Los_Angeles") + class TestDXContentPrimaryFieldTargetUrl(unittest.TestCase): layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING diff --git a/src/plone/restapi/tests/test_dxfield_deserializer.py b/src/plone/restapi/tests/test_dxfield_deserializer.py index 5a4670c786..735d7d460b 100644 --- a/src/plone/restapi/tests/test_dxfield_deserializer.py +++ b/src/plone/restapi/tests/test_dxfield_deserializer.py @@ -46,7 +46,7 @@ def setUp(self): self.portal.invokeFactory("DXTestDocument", id="doc1", title="Test Document") - def deserialize(self, fieldname, value): + def deserialize(self, fieldname, value, **deserializer_attrs): for schema in iterSchemata(self.portal.doc1): if fieldname in schema: field = schema.get(fieldname) @@ -54,6 +54,8 @@ def deserialize(self, fieldname, value): deserializer = getMultiAdapter( (field, self.portal.doc1, self.request), IFieldDeserializer ) + for k, v in deserializer_attrs.items(): + setattr(deserializer, k, v) return deserializer(value) def test_ascii_deserialization_returns_native_string(self): @@ -114,9 +116,19 @@ def test_date_deserialization_returns_date(self): self.assertTrue(isinstance(value, date)) self.assertEqual(date(2015, 12, 20), value) - def test_datetime_deserialization_defaults_to_timezone_from_request(self): + def test_datetime_deserialization_defaults_to_utc(self): + self.portal.doc1.test_datetime_field = None + value = self.deserialize("test_datetime_field", "2015-12-20T10:39:54.361") + self.assertEqual( + datetime(2015, 12, 20, 10, 39, 54, 361000, timezone("UTC")), value + ) + + def test_datetime_deserialization_converts_to_utc(self): self.portal.doc1.test_datetime_field = None value = self.deserialize("test_datetime_field", "2015-12-20T10:39:54.361+01") + self.assertEqual( + datetime(2015, 12, 20, 9, 39, 54, 361000, timezone("UTC")), value + ) self.assertEqual( timezone("Europe/Zurich").localize( datetime(2015, 12, 20, 10, 39, 54, 361000) @@ -124,13 +136,6 @@ def test_datetime_deserialization_defaults_to_timezone_from_request(self): value, ) - def test_datetime_deserialization_defaults_to_utc(self): - self.portal.doc1.test_datetime_field = None - value = self.deserialize("test_datetime_field", "2015-12-20T10:39:54.361") - self.assertEqual( - datetime(2015, 12, 20, 10, 39, 54, 361000, timezone("UTC")), value - ) - def test_datetime_deserialization_converts_to_existing_timezone(self): self.portal.doc1.test_datetime_field = timezone("Europe/Zurich").localize( datetime.now() @@ -182,6 +187,20 @@ def test_datetime_deserialization_required(self): with self.assertRaises(RequiredMissing): self.deserialize(field_name, None) + def test_datetime_deserialization_converts_to_requested_timezone(self): + value = self.deserialize( + "test_datetime_field", + "2015-12-20T10:39:54.361+00:00", + requested_timezone="Europe/Zurich", + ) + self.assertEqual( + timezone("Europe/Zurich").localize( + datetime(2015, 12, 20, 11, 39, 54, 361000) + ), + value, + ) + self.assertEqual("Europe/Zurich", value.tzinfo.zone) + def test_text_deserialization_returns_decimal(self): value = self.deserialize("test_decimal_field", "1.1") self.assertTrue(isinstance(value, Decimal), "Not a ") From deecfd0fb0bd273918dce38bda1fb04d94e2af92 Mon Sep 17 00:00:00 2001 From: David Glick Date: Fri, 29 May 2026 12:52:13 +0200 Subject: [PATCH 2/5] Python backwards-compat --- src/plone/restapi/serializer/utils.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/plone/restapi/serializer/utils.py b/src/plone/restapi/serializer/utils.py index 0749c7988a..cb8c8ff211 100644 --- a/src/plone/restapi/serializer/utils.py +++ b/src/plone/restapi/serializer/utils.py @@ -2,13 +2,21 @@ from plone.app.uuid.utils import uuidToCatalogBrain from plone.dexterity.schema import lookup_fti from plone.restapi.interfaces import IObjectPrimaryFieldTarget +from typing import Union from zope.component import queryMultiAdapter from zope.globalrequest import getRequest from zope.i18n import translate import pytz import re -import zoneinfo + +try: + from zoneinfo import ZoneInfo +except ImportError: + + class ZoneInfo: + key = None + RESOLVEUID_RE = re.compile("^(?:|.*/)resolve[Uu]id/([^/#]*)?(.*)?$") @@ -57,9 +65,9 @@ def get_portal_type_title(portal_type): return getattr(fti, "Title", lambda: portal_type)() -def get_timezone_name(dt: datetime) -> str | None: +def get_timezone_name(dt: datetime) -> Union[str, None]: tzinfo = dt.tzinfo - if isinstance(tzinfo, zoneinfo.ZoneInfo): + if isinstance(tzinfo, ZoneInfo): return tzinfo.key elif isinstance(tzinfo, pytz.tzinfo.BaseTzInfo) and tzinfo.zone: return tzinfo.zone From 198e31baced8d59882cd6de3f296d28f4dd109b1 Mon Sep 17 00:00:00 2001 From: David Glick Date: Fri, 29 May 2026 13:03:59 +0200 Subject: [PATCH 3/5] fix tests --- src/plone/restapi/tests/test_dxcontent_serializer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plone/restapi/tests/test_dxcontent_serializer.py b/src/plone/restapi/tests/test_dxcontent_serializer.py index 731ebcdbeb..5f95abff97 100644 --- a/src/plone/restapi/tests/test_dxcontent_serializer.py +++ b/src/plone/restapi/tests/test_dxcontent_serializer.py @@ -37,8 +37,8 @@ from zope.publisher.interfaces.browser import IBrowserRequest import json +import pytz import unittest -import zoneinfo HAS_PLONE_6 = getattr( import_module("Products.CMFPlone.factory"), "PLONE60MARKER", False @@ -804,7 +804,7 @@ def test_get_layout_for_siteroot(self): self.assertEqual(current_layout, obj["layout"]) def test_serializer_includes_event_timezones(self): - tz = zoneinfo.ZoneInfo("America/Los_Angeles") + tz = pytz.timezone("America/Los_Angeles") self.portal.invokeFactory( "Event", id="event1", From a5494c94dbc14509083a36e9f8cc561e72a24231 Mon Sep 17 00:00:00 2001 From: David Glick Date: Fri, 29 May 2026 13:30:20 +0200 Subject: [PATCH 4/5] Add start_timezone and end_timezone to summary serialization --- src/plone/restapi/serializer/summary.py | 3 +++ src/plone/restapi/serializer/utils.py | 5 +++- .../tests/test_dxcontent_serializer.py | 4 ++-- .../restapi/tests/test_serializer_summary.py | 23 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/plone/restapi/serializer/summary.py b/src/plone/restapi/serializer/summary.py index a18ed98a8c..d554335bc9 100644 --- a/src/plone/restapi/serializer/summary.py +++ b/src/plone/restapi/serializer/summary.py @@ -5,6 +5,7 @@ from plone.restapi.interfaces import ISerializeToJsonSummary from plone.restapi.serializer.converters import json_compatible from plone.restapi.serializer.utils import get_portal_type_title +from plone.restapi.serializer.utils import get_timezone_name from Products.CMFCore.utils import getToolByName from Products.CMFCore.WorkflowCore import WorkflowException from zope.component import adapter @@ -101,6 +102,8 @@ def __call__(self): summary[field] = None continue summary[field] = json_compatible(value) + if field in ("start", "end"): + summary[f"{field}_timezone"] = get_timezone_name(value) return summary def metadata_fields(self): diff --git a/src/plone/restapi/serializer/utils.py b/src/plone/restapi/serializer/utils.py index cb8c8ff211..3937f5cac8 100644 --- a/src/plone/restapi/serializer/utils.py +++ b/src/plone/restapi/serializer/utils.py @@ -2,6 +2,7 @@ from plone.app.uuid.utils import uuidToCatalogBrain from plone.dexterity.schema import lookup_fti from plone.restapi.interfaces import IObjectPrimaryFieldTarget +from typing import Optional from typing import Union from zope.component import queryMultiAdapter from zope.globalrequest import getRequest @@ -65,7 +66,9 @@ def get_portal_type_title(portal_type): return getattr(fti, "Title", lambda: portal_type)() -def get_timezone_name(dt: datetime) -> Union[str, None]: +def get_timezone_name(dt: Optional[datetime]) -> Union[str, None]: + if dt is None: + return None tzinfo = dt.tzinfo if isinstance(tzinfo, ZoneInfo): return tzinfo.key diff --git a/src/plone/restapi/tests/test_dxcontent_serializer.py b/src/plone/restapi/tests/test_dxcontent_serializer.py index 5f95abff97..ef7fab2024 100644 --- a/src/plone/restapi/tests/test_dxcontent_serializer.py +++ b/src/plone/restapi/tests/test_dxcontent_serializer.py @@ -808,8 +808,8 @@ def test_serializer_includes_event_timezones(self): self.portal.invokeFactory( "Event", id="event1", - start=datetime(2026, 5, 29, 0, tzinfo=tz), - end=datetime(2026, 5, 29, 1, tzinfo=tz), + start=tz.localize(datetime(2026, 5, 29, 0)), + end=tz.localize(datetime(2026, 5, 29, 1)), ) event = self.portal.event1 obj = self.serialize(event) diff --git a/src/plone/restapi/tests/test_serializer_summary.py b/src/plone/restapi/tests/test_serializer_summary.py index cfd950af26..7d7588f401 100644 --- a/src/plone/restapi/tests/test_serializer_summary.py +++ b/src/plone/restapi/tests/test_serializer_summary.py @@ -216,6 +216,29 @@ def test_dx_type_summary(self): summary, ) + def test_event_brain_summary(self): + tz = pytz.timezone("America/Los_Angeles") + self.event1 = createContentInContainer( + self.portal, + "Event", + id="event1", + title="Test event", + start=tz.localize(datetime(2026, 5, 29, 0)), + end=tz.localize(datetime(2026, 5, 29, 1)), + ) + brain = self.catalog(UID=self.event1.UID())[0] + self.request.form.update({"metadata_fields": "_all"}) + summary = getMultiAdapter((brain, self.request), ISerializeToJsonSummary)() + self.assertLessEqual( + { + "start": "2026-05-29T07:00:00+00:00", + "start_timezone": "America/Los_Angeles", + "end": "2026-05-29T08:00:00+00:00", + "end_timezone": "America/Los_Angeles", + }.items(), + summary.items(), + ) + class TestSummarySerializerswithRecurrenceObjects(unittest.TestCase): layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING From 563bb028069fb8f6050c3a6243da21c9b71ec047 Mon Sep 17 00:00:00 2001 From: David Glick Date: Fri, 29 May 2026 13:40:08 +0200 Subject: [PATCH 5/5] fix --- src/plone/restapi/serializer/summary.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plone/restapi/serializer/summary.py b/src/plone/restapi/serializer/summary.py index d554335bc9..eb69278637 100644 --- a/src/plone/restapi/serializer/summary.py +++ b/src/plone/restapi/serializer/summary.py @@ -1,3 +1,4 @@ +from datetime import datetime from plone.app.contentlisting.interfaces import IContentListingObject from plone.restapi.bbb import IPloneSiteRoot from plone.restapi.deserializer import json_body @@ -102,7 +103,7 @@ def __call__(self): summary[field] = None continue summary[field] = json_compatible(value) - if field in ("start", "end"): + if field in ("start", "end") and isinstance(value, datetime): summary[f"{field}_timezone"] = get_timezone_name(value) return summary