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