From c7503b7d71c142f06f9986ad0877e9db33537804 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Wed, 7 Oct 2026 08:30:53 +0200 Subject: [PATCH] Support DX Decimal fields via a field manager A `zope.schema.Decimal` only accepts a `decimal.Decimal`, and JSON has no type for one. A price arrived as a string, a float or an integer and every one of them failed the field's own validation, so a Decimal field could not be written through the API at all. The new DecimalFieldManager coerces those three on set and serializes back to a string, which is the only JSON form that keeps the exact value. 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. The manager also joins NORMALIZING_FIELD_MANAGERS. Without that the data manager prefers the content type's own set mutator, which stores the value exactly as it arrives: `setAnalysisProfilePrice` calls its mutator with the raw string, and the object then fails validation with "wrong type". This is the same reason UID references and durations are in that list. `analysis_profile_price` and `analysis_profile_vat` on AnalysisProfile are the only Decimal fields in the stack today, and neither setter does anything beyond calling its mutator, so nothing is lost by going through the field manager instead. --- docs/changelog.rst | 1 + src/senaite/jsonapi/configure.zcml | 6 +++ src/senaite/jsonapi/datamanagers.py | 14 ++++-- src/senaite/jsonapi/fieldmanagers.py | 45 +++++++++++++++++++ src/senaite/jsonapi/tests/doctests/create.rst | 24 ++++++++++ 5 files changed, 86 insertions(+), 4 deletions(-) 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 ~~~~~~~~~~~~~~~~~