diff --git a/docs/changelog.rst b/docs/changelog.rst
index 3478b8d..750a2b3 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.
+- #114 Support DX Decimal fields via a field manager
- #109 Add a partition operation endpoint
- #106 Fix single-valued UID reference fields not settable through the JSON API
- #112 Allow updating the Laboratory through the API
diff --git a/src/senaite/jsonapi/configure.zcml b/src/senaite/jsonapi/configure.zcml
index d523003..17aff78 100644
--- a/src/senaite/jsonapi/configure.zcml
+++ b/src/senaite/jsonapi/configure.zcml
@@ -271,6 +271,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)
+# a duration mapping to a timedelta, a number to a Decimal, ...). For
+# these the raw set 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,
+ DecimalFieldManager,
+)
class BaseDataManager(object):
diff --git a/src/senaite/jsonapi/fieldmanagers.py b/src/senaite/jsonapi/fieldmanagers.py
index befd2b2..394ac0b 100644
--- a/src/senaite/jsonapi/fieldmanagers.py
+++ b/src/senaite/jsonapi/fieldmanagers.py
@@ -25,6 +25,8 @@
from AccessControl import Unauthorized
from bika.lims import api as bika_api
from datetime import timedelta
+from decimal import Decimal
+from decimal import InvalidOperation
from DateTime import DateTime
from Products.Archetypes.utils import mapply
from senaite.core.schema.uidreferencefield import UIDReferenceField
@@ -202,6 +204,49 @@ def to_mapping(value):
}
+class DecimalFieldManager(ZopeSchemaFieldManager):
+ """Adapter to get/set DX Decimal field values.
+
+ The stored value is a `decimal.Decimal`, which JSON has no type for.
+ A price arrives as a string, a float or an integer and every one of
+ them fails the field's own validation, so a Decimal field could not
+ be written through the API at all. Accept all three on set and
+ serialize back to a string, which is the only JSON form that keeps
+ the exact value.
+ """
+ interface.implements(IFieldManager)
+
+ def set(self, instance, value, **kw):
+ value = self.to_decimal(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, Decimal):
+ return default
+ return str(value)
+
+ @staticmethod
+ def to_decimal(value):
+ """Coerce a JSON value to Decimal, leaving anything else alone.
+
+ A value that is not a number is handed on untouched so the field
+ reports it, rather than this adapter raising an error of its own
+ for a value the field would describe better.
+ """
+ if value is None or isinstance(value, Decimal):
+ return value
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (six.string_types, int, float)):
+ try:
+ return Decimal(str(value).strip())
+ except (InvalidOperation, ValueError):
+ return value
+ return value
+
+
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 0f2fa2e..dfe04e3 100644
--- a/src/senaite/jsonapi/tests/doctests/create.rst
+++ b/src/senaite/jsonapi/tests/doctests/create.rst
@@ -350,6 +350,30 @@ directly; it accepts a `{days, hours, minutes, seconds}` mapping:
>>> sample_point.sampling_frequency
datetime.timedelta(7)
+A DX Decimal field has no JSON type either. It takes the number as a
+string, which is the only form that keeps the exact value, and also
+accepts a plain number:
+
+ >>> data = {"portal_type": "AnalysisProfile",
+ ... "parent_path": api.get_path(portal.setup.analysisprofiles),
+ ... "title": "Basic Panel",
+ ... "analysis_profile_price": u"24.00",
+ ... "analysis_profile_vat": 7}
+ >>> profile = create_json(data)
+ >>> profile.analysis_profile_price
+ Decimal('24.00')
+ >>> profile.analysis_profile_vat
+ Decimal('7')
+
+It reads back as a string:
+
+ >>> from senaite.jsonapi.interfaces import IFieldManager
+ >>> from senaite.jsonapi.api import get_field
+ >>> field = get_field(profile, "analysis_profile_price")
+ >>> IFieldManager(field).json_data(profile)
+ '24.00'
+
+
Creating a Sample
~~~~~~~~~~~~~~~~~