Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 19 additions & 8 deletions docs/source/usage/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,25 @@ Basic Python data types that have a corresponding type in JSON, such as integers
## Dates and Times

Since JSON does not have native support for dates and times, the Python and Zope `datetime` types will be serialized to an ISO 8601 date string.

| Python | JSON |
| ------------------------------------ | ----------------------- |
| `time(19, 45, 55)` | `"19:45:55"` |
| `date(2015, 11, 23)` | `"2015-11-23"` |
| `datetime(2015, 11, 23, 19, 45, 55)` | `"2015-11-23T19:45:55"` |
| `DateTime("2015/11/23 19:45:55")` | `"2015-11-23T19:45:55"` |

Timezone-aware values are converted to UTC.

| Python | JSON |
| ------------------------------------------------------------------ | ----------------------------- |
| `time(19, 45, 55)` | `"19:45:55"` |
| `date(2015, 11, 23)` | `"2015-11-23"` |
| `datetime(2015, 11, 23, 19, 45, 55)` | `"2015-11-23T19:45:55"` |
| `datetime(2015, 11, 23, 19, 45, tzinfo=ZoneInfo("Europe/Vienna"))` | `"2015-11-23T17:45:00+00:00"` |
| `DateTime("2015/11/23 19:45:55 UTC")` | `"2015-11-23T19:45:55+00:00"` |

Event types additionally expose `start_timezone` and `end_timezone` fields.
The `start` and `end` datetime values are still serialized and deserialized as UTC values with an offset of `+00:00`, but `start_timezone` and `end_timezone` allow the client (or plone.restapi) to convert the datetime values to the requested timezone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ambiguity of whether the client or plone.restapi converts values to a requested timezone leaves it open for double conversion. This should be disambiguated, making it clear where the responsibility of conversion occurs. If both may convert values, then we should include the conditions for conversion.

@davisagli davisagli Jun 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The REST API accepts datetime values as an ISO 8601 datetime with any offset. (If no offset was specified, we assume it was intended to be UTC.) It then converts these for storage as Python datetimes as follows:

  1. If it's a start value with start_timezone specified, localize it to that timezone. If it's an end value with end_timezone specified, localize it to that timezone.
  2. Otherwise, if there's already a timezone-aware value stored in the field, localize to its timezone.
  3. Otherwise (no value stored yet, and no specific timezone specified in the request), store as a timezone-aware datetime in UTC.

So: a client may send values with whatever offset is convenient, but it should specify the offset and it should also specify start_timezone or end_timezone to make sure that the backend can store the intended timezone.


| field name | Python | JSON |
| ---------------- | ---------------------------------------------------------------- | ----------------------------- |
| `start` | `datetime(2026, 5, 26, 10, 0, tzinfo=ZoneInfo("Europe/Vienna"))` | `"2026-05-26T08:00:00+00:00"` |
| `start_timezone` | `"Europe/Vienna"` | `"Europe/Vienna"` |
| `end` | `datetime(2026, 5, 31, 10, 0, tzinfo=ZoneInfo("Europe/Vienna"))` | `"2026-05-31T08:00:00+00:00"` |
| `end_timezone` | `"Europe/Vienna"` | `"Europe/Vienna"` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To complete a round-trip, how will a POST, PUT, or PATCH request handle a JSON object with or without a start_timezone or end_timezone?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

@stevepiercy stevepiercy Jun 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| `end_timezone` | `"Europe/Vienna"` | `"Europe/Vienna"` |
| `end_timezone` | `"Europe/Vienna"` | `"Europe/Vienna"` |
During deserialization, plone.restapi processes both the ISO 8601 string, and `start_timezone` and `end_timezone` if present, from the JSON object, storing the field's value as described below.
- If the field is either a start value with `start_timezone` specified, or an end value with `end_timezone` specified, localize it to the timezone from the request.
- If the request lacks a specified `start_timezone` or `end_timezone` for the respective start or end value of the field, and if the field already has a timezone-aware value, localize the string to the field's timezone.
- Finally, if the field both has no value and there's no timezone in the request, store the value as a timezone-aware datetime in UTC.

How about this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the client sends a POST, PUT, or PATCH request

Do we have to be quite so specific here? This information is about deserialization, which happens for data in those kinds of requests, but also when the deserializer is called in other contexts that aren't tied to a request method (such as from import tools). I would just say "During deserialization" here and make sure the beginning of the chapter explains what that means.

to the field's timezone

That sounds like the field is configured with a timezone which isn't the case. I would say "to the timezone of the existing value"

Otherwise this looks good

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK on the first point. I'll tweak that tonight.

On the second point, you originally wrote this:

  1. Otherwise, if there's already a timezone-aware value stored in the field, localize to its timezone.

I want to make sure that "its timezone" doesn't refer to "the field" as the sentence's subject (that's how I interpreted it) but something external to that sentence, specifically the ISO 8601 string value. Can you confirm?

Words are hard.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stevepiercy If there is already a timezone-aware datetime value stored and there was no timezone specified in the serialized data, then it localizes the new value to the timezone of the existing stored value. https://github.com/plone/plone.restapi/pull/2018/files#diff-739c4c63bfbbf88cb8766b29dba0b84591a8709e67a5ed5bb55954539e4e7e27L126-L131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davisagli I edited the first bullet point, and added a new suggestion higher up the page.

Can you please double-check for the second and third bullet points? The first bullet point describes the code in your link. The second and third bullet points address the code at https://github.com/plone/plone.restapi/pull/2018/changes#diff-739c4c63bfbbf88cb8766b29dba0b84591a8709e67a5ed5bb55954539e4e7e27R134-R149.


## Decimal Type

Expand Down
3 changes: 3 additions & 0 deletions news/1878.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add `start_timezone` and `end_timezone` to serialized Events.
During deserialization, store values in these timezones if specified.
@davisagli
5 changes: 5 additions & 0 deletions src/plone/restapi/deserializer/dxcontent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that the original issue requests adding timezone serialization for events, but shouldn't we do this for all datetime fields?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't assume that. We've only been thinking about events as we design the solution.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I'll keep this in mind, but first I want to make sure everything works end to end with the frontend widgets.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, we should do it for all at one point. But for now out of scope I think.

try:
value = deserializer(data[name])
except ValueError as e:
Expand Down
12 changes: 12 additions & 0 deletions src/plone/restapi/deserializer/dxfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davisagli I was chewing on this, as I told you at the Buschenschanksprint - but checking the rest api and your PR‌ there, I recognize that all datetimes are always converted to UTC for serialization/deserialization.
So, calculating the correct start/end time for the requested timezone here is absolutely right.
👍

else:
# Otherwise let's check what is currently stored.
dm = queryMultiAdapter((self.context, self.field), IDataManager)
Expand Down
7 changes: 7 additions & 0 deletions src/plone/restapi/serializer/dxcontent.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
)()
Expand Down
4 changes: 4 additions & 0 deletions src/plone/restapi/serializer/summary.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from datetime import datetime
from plone.app.contentlisting.interfaces import IContentListingObject
from plone.restapi.bbb import IPloneSiteRoot
from plone.restapi.deserializer import json_body
from plone.restapi.interfaces import IJSONSummarySerializerMetadata
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
Expand Down Expand Up @@ -101,6 +103,8 @@ def __call__(self):
summary[field] = None
continue
summary[field] = json_compatible(value)
if field in ("start", "end") and isinstance(value, datetime):
summary[f"{field}_timezone"] = get_timezone_name(value)
return summary

def metadata_fields(self):
Expand Down
24 changes: 24 additions & 0 deletions src/plone/restapi/serializer/utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
from datetime import datetime
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
from zope.i18n import translate

import pytz
import re

try:
from zoneinfo import ZoneInfo
except ImportError:

class ZoneInfo:
key = None


RESOLVEUID_RE = re.compile("^(?:|.*/)resolve[Uu]id/([^/#]*)?(.*)?$")


Expand Down Expand Up @@ -52,3 +64,15 @@ 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: Optional[datetime]) -> Union[str, None]:
if dt is None:
return None
tzinfo = dt.tzinfo
if isinstance(tzinfo, 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)
2 changes: 2 additions & 0 deletions src/plone/restapi/tests/http-examples/event.resp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src/plone/restapi/tests/test_dxcontent_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from zope.publisher.interfaces.browser import IBrowserRequest

import json
import pytz
import unittest

HAS_PLONE_6 = getattr(
Expand Down Expand Up @@ -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 = pytz.timezone("America/Los_Angeles")
self.portal.invokeFactory(
"Event",
id="event1",
start=tz.localize(datetime(2026, 5, 29, 0)),
end=tz.localize(datetime(2026, 5, 29, 1)),
)
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
Expand Down
37 changes: 28 additions & 9 deletions src/plone/restapi/tests/test_dxfield_deserializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ 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)
break
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):
Expand Down Expand Up @@ -114,23 +116,26 @@ 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)
),
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()
Expand Down Expand Up @@ -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 <Decimal>")
Expand Down
23 changes: 23 additions & 0 deletions src/plone/restapi/tests/test_serializer_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down