Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/senaite/jsonapi/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@
factory=".fieldmanagers.DatetimeFieldManager"
/>

<!-- Adapter for DX Duration (Timedelta) Fields -->
<adapter
for="senaite.core.schema.interfaces.IDurationField"
factory=".fieldmanagers.DurationFieldManager"
/>

<!-- Adapter for Named Image Fields -->
<adapter
for="plone.namedfile.interfaces.INamedImageField"
Expand Down
20 changes: 14 additions & 6 deletions src/senaite/jsonapi/datamanagers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@
from senaite.jsonapi import api
from senaite.jsonapi.interfaces import IDataManager
from senaite.jsonapi.interfaces import IFieldManager
from senaite.jsonapi.fieldmanagers import DurationFieldManager
from senaite.jsonapi.fieldmanagers import UIDReferenceFieldMixin

# Field managers that normalize the incoming value (resolve UIDs, coerce
# a duration mapping to a timedelta, ...). For these the raw set<Name>
# 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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions src/senaite/jsonapi/fieldmanagers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
"""
Expand Down
11 changes: 11 additions & 0 deletions src/senaite/jsonapi/tests/doctests/create.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~
Expand Down
Loading