diff --git a/docs/changelog.rst b/docs/changelog.rst
index 0e653e0..5f0896d 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.
+- #102 Support DX Duration (Timedelta) fields via a field manager
- #101 Encode AT string field values to native str before validation
- #100 Normalize UID references through the field manager, not the setter
- #99 Report the reason when object creation fails
diff --git a/src/senaite/jsonapi/configure.zcml b/src/senaite/jsonapi/configure.zcml
index 884a5bf..cd6046e 100644
--- a/src/senaite/jsonapi/configure.zcml
+++ b/src/senaite/jsonapi/configure.zcml
@@ -258,6 +258,12 @@
factory=".fieldmanagers.DatetimeFieldManager"
/>
+
+
+
+# mutator would store the value unconverted, so the data manager must go
+# through the field manager instead of the setter.
+NORMALIZING_FIELD_MANAGERS = (UIDReferenceFieldMixin, DurationFieldManager)
+
class BaseDataManager(object):
"""Base Data Manager
@@ -231,14 +238,15 @@ def set(self, name, value, **kw):
field = api.get_field(self.context, name)
- # UID reference fields must be set via their field manager, which
- # normalizes the value (resolves objects/paths and coerces UIDs
- # to native str). A raw setter would store the value as given --
- # e.g. a unicode UID from a JSON payload -- which then fails the
- # field's ASCIILine value_type validation with WrongContainedType.
+ # Fields whose manager normalizes the value must be set via that
+ # manager (e.g. UID references coerced to native str, or a
+ # duration mapping coerced to a timedelta). A raw setter would
+ # store the value as given -- a unicode UID that fails the
+ # ASCIILine value_type, or a dict that a Timedelta field rejects
+ # as "wrong type".
if field is not None:
fieldmanager = IFieldManager(field)
- if isinstance(fieldmanager, UIDReferenceFieldMixin):
+ if isinstance(fieldmanager, NORMALIZING_FIELD_MANAGERS):
return fieldmanager.set(self.context, value, **kw)
# Otherwise prefer a content-type setter: it may carry side
diff --git a/src/senaite/jsonapi/fieldmanagers.py b/src/senaite/jsonapi/fieldmanagers.py
index 298b9f7..4b24d05 100644
--- a/src/senaite/jsonapi/fieldmanagers.py
+++ b/src/senaite/jsonapi/fieldmanagers.py
@@ -24,6 +24,7 @@
import six
from AccessControl import Unauthorized
from bika.lims import api as bika_api
+from datetime import timedelta
from DateTime import DateTime
from Products.Archetypes.utils import mapply
from senaite.core.schema.uidreferencefield import UIDReferenceField
@@ -149,6 +150,58 @@ def json_data(self, instance, default=None):
return api.to_iso_date(value, default=default)
+class DurationFieldManager(ZopeSchemaFieldManager):
+ """Adapter to get/set DX DurationField (zope.schema Timedelta) values.
+
+ The stored value is a `datetime.timedelta`, which JSON cannot carry.
+ Accept a `{"days", "hours", "minutes", "seconds"}` mapping (or a
+ number of minutes, or a timedelta) on set, and serialize back to that
+ mapping.
+ """
+ interface.implements(IFieldManager)
+
+ UNITS = ("weeks", "days", "hours", "minutes", "seconds")
+
+ def set(self, instance, value, **kw):
+ value = self.to_timedelta(value)
+ self.field.validate(value)
+ return self.field.set(instance, value)
+
+ def json_data(self, instance, default=None):
+ value = self.get(instance)
+ if not isinstance(value, timedelta):
+ return default
+ return self.to_mapping(value)
+
+ @classmethod
+ def to_timedelta(cls, value):
+ if value is None or isinstance(value, timedelta):
+ return value
+ if isinstance(value, dict):
+ kwargs = {}
+ for unit in cls.UNITS:
+ num = value.get(unit)
+ if num:
+ kwargs[unit] = float(num)
+ return timedelta(**kwargs)
+ if isinstance(value, (int, float)):
+ return timedelta(minutes=value)
+ return value
+
+ @staticmethod
+ def to_mapping(value):
+ total = int(value.total_seconds())
+ days, rem = divmod(total, 86400)
+ hours, rem = divmod(rem, 3600)
+ minutes, seconds = divmod(rem, 60)
+ return {
+ "days": days,
+ "hours": hours,
+ "minutes": minutes,
+ "seconds": seconds,
+ }
+
+
class RichTextFieldManager(ZopeSchemaFieldManager):
"""Adapter to get/set the value of Rich Text Fields
"""
diff --git a/src/senaite/jsonapi/tests/doctests/create.rst b/src/senaite/jsonapi/tests/doctests/create.rst
index 1d4bde3..0f2fa2e 100644
--- a/src/senaite/jsonapi/tests/doctests/create.rst
+++ b/src/senaite/jsonapi/tests/doctests/create.rst
@@ -339,6 +339,17 @@ them (they expect a native `str`):
>>> nitrate.getPrice()
'12.50'
+A DX Duration field (`zope.schema.Timedelta`) cannot be carried by JSON
+directly; it accepts a `{days, hours, minutes, seconds}` mapping:
+
+ >>> data = {"portal_type": "SamplePoint",
+ ... "parent_path": api.get_path(portal.setup.samplepoints),
+ ... "title": "Well 1",
+ ... "sampling_frequency": {"days": 7}}
+ >>> sample_point = create_json(data)
+ >>> sample_point.sampling_frequency
+ datetime.timedelta(7)
+
Creating a Sample
~~~~~~~~~~~~~~~~~