diff --git a/docs/changelog.rst b/docs/changelog.rst index 622ddf3..7a3c8e6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,7 @@ Changelog applied. Uninstalling from the same panel removes the PAS plugin and the per-user JWT signing secrets. +- #110 Fix /registry route failing on datetime registry values - #108 Add introspection endpoints for types, schema and workflow - #107 Validate only the submitted fields on update - #109 Add a partition operation endpoint diff --git a/src/senaite/jsonapi/api/settings.py b/src/senaite/jsonapi/api/settings.py index 28bf208..9d8ca54 100644 --- a/src/senaite/jsonapi/api/settings.py +++ b/src/senaite/jsonapi/api/settings.py @@ -33,6 +33,7 @@ from Products.CMFPlone.interfaces.controlpanel import IMaintenanceSchema from Products.CMFPlone.interfaces.controlpanel import ISecuritySchema from Products.CMFPlone.interfaces.controlpanel import IUserGroupsSettingsSchema # noqa: E501 +from senaite.core.api import dtime from zope.component import getAdapter from zope.schema import getFieldNames @@ -46,6 +47,28 @@ } +def to_json_value(value): + """Coerce a registry value into a JSON serializable form. + + Registry records may hold dates or datetimes (and containers of them), + which the JSON encoder used by the route cannot serialize. + + :param value: The raw registry value + :returns: A JSON serializable value + """ + if isinstance(value, (list, tuple)): + return [to_json_value(item) for item in value] + if isinstance(value, dict): + return {key: to_json_value(val) for key, val in value.items()} + # datetime and Zope DateTime + if dtime.is_dt(value) or dtime.is_DT(value): + return dtime.to_iso_format(value) + # pure date, which to_iso_format does not handle + if dtime.is_d(value): + return value.isoformat() + return value + + def get_registry_records_by_keyword(keyword=None): """Return registry records whose name contains `keyword`. @@ -55,7 +78,8 @@ def get_registry_records_by_keyword(keyword=None): records = {} for record in portal_reg.records: if keyword is None or keyword.lower() in record.lower(): - records[record] = bika_api.get_registry_record(record) + value = bika_api.get_registry_record(record) + records[record] = to_json_value(value) return records diff --git a/src/senaite/jsonapi/tests/doctests/api_settings.rst b/src/senaite/jsonapi/tests/doctests/api_settings.rst index 7c0f434..26deb22 100644 --- a/src/senaite/jsonapi/tests/doctests/api_settings.rst +++ b/src/senaite/jsonapi/tests/doctests/api_settings.rst @@ -137,6 +137,114 @@ filtered subset: True +Non JSON serializable values are coerced +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some registry records hold `datetime` values, which the JSON encoder used +by the route cannot serialize. Such values are coerced to ISO strings: + + >>> from datetime import datetime + >>> from plone.registry import field + >>> from plone.registry.record import Record + >>> reg = ploneapi.portal.get_tool("portal_registry") + >>> reg.records["senaite.jsonapi.test_datetime"] = Record( + ... field.Datetime(title=u"Test"), + ... datetime(2014, 8, 14, 0, 0, 0, 3)) + >>> transaction.commit() + + >>> hits = api_settings.get_registry_records_by_keyword("test_datetime") + >>> hits["senaite.jsonapi.test_datetime"] + '2014-08-14T00:00:00.000003' + +So the whole record set is now JSON serializable: + + >>> ignored = json.dumps( + ... api_settings.get_registry_records_by_keyword(None)) + +`to_json_value` does the coercion. Plain JSON values are returned as is: + + >>> to_json_value = api_settings.to_json_value + >>> to_json_value("text") + 'text' + >>> to_json_value(u"text") + u'text' + >>> to_json_value(1), to_json_value(1.5) + (1, 1.5) + >>> to_json_value(True), to_json_value(None) + (True, None) + +A `datetime` is converted with `isoformat`, keeping its timezone if any: + + >>> import pytz + >>> to_json_value(datetime(2014, 8, 14, 10, 30)) + '2014-08-14T10:30:00' + >>> to_json_value(datetime(2014, 8, 14, 10, 30, tzinfo=pytz.utc)) + '2014-08-14T10:30:00+00:00' + +A `date` is converted with `isoformat` too: + + >>> from datetime import date + >>> to_json_value(date(2014, 8, 14)) + '2014-08-14' + +A Zope `DateTime` is converted with `DateTime.ISO`, keeping its timezone: + + >>> from DateTime import DateTime + >>> to_json_value(DateTime("2014/08/14 10:30:00 UTC")) + '2014-08-14T10:30:00+00:00' + >>> to_json_value(DateTime("2014/08/14 10:30:00 GMT+2")) + '2014-08-14T10:30:00+02:00' + +Lists and tuples are converted item by item. Both come back as a list: + + >>> to_json_value([date(2014, 8, 14), "text", 1]) + ['2014-08-14', 'text', 1] + >>> to_json_value((date(2014, 8, 14), "text")) + ['2014-08-14', 'text'] + >>> to_json_value([]), to_json_value(()) + ([], []) + +Dicts keep their keys and have their values converted: + + >>> to_json_value({"day": date(2014, 8, 14), "count": 1}) == { + ... "day": "2014-08-14", "count": 1} + True + >>> to_json_value({}) + {} + +Nested containers are converted at every level: + + >>> value = {"days": [date(2014, 8, 14), (datetime(2014, 8, 14, 10),)], + ... "inner": {"when": DateTime("2014/08/14 10:30:00 UTC")}} + >>> to_json_value(value) == { + ... "days": ["2014-08-14", ["2014-08-14T10:00:00"]], + ... "inner": {"when": "2014-08-14T10:30:00+00:00"}} + True + >>> ignored = json.dumps(to_json_value(value)) + +The value passed in is not modified: + + >>> value["days"][0] + datetime.date(2014, 8, 14) + +Registry records of the other date-like and container field types are +coerced on the way out as well: + + >>> reg.records["senaite.jsonapi.test_date"] = Record( + ... field.Date(title=u"Test"), date(2014, 8, 14)) + >>> reg.records["senaite.jsonapi.test_datetimes"] = Record( + ... field.List(title=u"Test", value_type=field.Datetime()), + ... [datetime(2014, 8, 14, 10, 30)]) + >>> transaction.commit() + + >>> hits = api_settings.get_registry_records_by_keyword( + ... "senaite.jsonapi.test_") + >>> hits["senaite.jsonapi.test_date"] + '2014-08-14' + >>> hits["senaite.jsonapi.test_datetimes"] + ['2014-08-14T10:30:00'] + + get_settings_by_keyword via the /settings route ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~