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
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.)

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))
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