Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rely on fields when validating submitted values on sample creation #2307

Merged
merged 12 commits into from
May 11, 2023
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Changelog
2.5.0 (unreleased)
------------------

- #2307 Rely on fields when validating submitted values on sample creation
- #2305 Add support for dates in ANSI X3.30 and ANSI X3.43.3 formats
- #2304 Fix dynamic sample specification not applied for new samples
- #2303 Fix managed permission of analysis workflow for lab roles
Expand Down
26 changes: 21 additions & 5 deletions src/bika/lims/browser/analysisrequest/add2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1704,13 +1704,29 @@ def ajax_submit(self):
msg = _("Field '{}' is required").format(safe_unicode(field))
fielderrors[fieldname] = msg

# Process valid record
# Process and validate field values
valid_record = dict()
for fieldname, fieldvalue in six.iteritems(record):
# clean empty
if fieldvalue in ['', None]:
tmp_sample = self.get_ar()
for field in fields:
field_name = field.getName()
field_value = record.get(field_name)
if field_value in ['', None]:
continue

# process the value as the widget would usually do
process_value = field.widget.process_form
value, msgs = process_value(tmp_sample, field, record)
if not value:
continue
valid_record[fieldname] = fieldvalue

# store the processed value as the valid record
valid_record[field_name] = value

# validate the value
error = field.validate(value, tmp_sample)
if error:
field_name = "{}-{}".format(field_name, num)
fielderrors[field_name] = error

# add the attachments to the record
valid_record["attachments"] = filter(None, attachments)
Expand Down
18 changes: 17 additions & 1 deletion src/senaite/core/api/dtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def ansi_to_dt(dt):
by ANSI X3.43.3 Date and time together shall be specified as up to a
14-character string: YYYYMMDD[HHMMSS]
:param str:
:return:
:return: datetime object
"""
if not is_str(dt):
raise TypeError("Type is not supported")
Expand All @@ -190,6 +190,22 @@ def ansi_to_dt(dt):
return datetime.strptime(dt, date_format)


def to_ansi(dt, show_time=True):
"""Returns the date in ANSI X3.30/X4.43.3) format
:param dt: DateTime/datetime/date
:param show_time: if true, returns YYYYMMDDHHMMSS. YYYYMMDD otherwise
:returns: str that represents the datetime in ANSI format
"""
dt = to_dt(dt)
if dt is None:
return None

ansi = "{:04d}{:02d}{:02d}".format(dt.year, dt.month, dt.day)
if not show_time:
return ansi
return "{}{:02d}{:02d}{:02d}".format(ansi, dt.hour, dt.minute, dt.second)


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better use strftime from the datetime module:

In [1]: from datetime import datetime

In [2]: now = datetime.now()

In [3]: now.strftime("%Y%m%d%H%M")
Out[3]: '202305110732'

def get_timezone(dt, default="Etc/GMT"):
"""Get a valid pytz timezone of the datetime object

Expand Down
129 changes: 129 additions & 0 deletions src/senaite/core/browser/fields/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@
from Products.Archetypes.public import DateTimeField as BaseField
from Products.Archetypes.Registry import registerField
from Products.Archetypes.Registry import registerPropertyType
from senaite.core.api import dtime
from senaite.core.browser.widgets.datetimewidget import DateTimeWidget
from zope.i18n import translate
from zope.i18nmessageid import Message

from bika.lims import _
from bika.lims import api

WIDGET_NOPAST = "datepicker_nopast"
WIDGET_NOFUTURE = "datepicker_nofuture"
WIDGET_SHOWTIME = "show_time"


class DateTimeField(BaseField):
Expand All @@ -42,6 +52,125 @@ class DateTimeField(BaseField):
})
security = ClassSecurityInfo()

def validate(self, value, instance, errors=None, **kwargs):
"""Validate passed-in value using all field validators plus the
validators for minimum and maximum date values
Return None if all validations pass; otherwise, return the message of
of the validation failure translated to current language
"""
# Rely on the super-class first
error = super(DateTimeField, self).validate(
value, instance, errors=errors, **kwargs)
if error:
return error

# Validate value is after min date
error = self.validate_min_date(value, instance, errors=errors)
if error:
return error

# Validate value is before max date
error = self.validate_max_date(value, instance, errors=errors)
if error:
return error

def validate_min_date(self, value, instance, errors=None):
"""Validates the passed-in value against the field's minimum date
"""
if errors is None:
errors = {}

# self.min always returns an offset-naive datetime, but the value
# is offset-aware. We need to add the TZ, otherwise we get a:
# TypeError: can't compare offset-naive and offset-aware datetimes
if dtime.to_ansi(value) >= dtime.to_ansi(self.min):
return None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, ANSI times can be compared as strings, then it's fine like this.


error = _(
u"error_datetime_before_min",
default=u"${name} is before ${min_date}, please correct.",
mapping={
"name": self.get_label(instance),
"min_date": self.localize(self.min, instance)
}
)

field_name = self.getName()
errors[field_name] = translate(error, context=api.get_request())
return errors[field_name]

def validate_max_date(self, value, instance, errors=None):
"""Validates the passed-in value against the field's maximum date
"""
if errors is None:
errors = {}

# self.max always returns an offset-naive datetime, but the value
# is offset-aware. We need to add the TZ, otherwise we get a:
# TypeError: can't compare offset-naive and offset-aware datetimes
if dtime.to_ansi(value) <= dtime.to_ansi(self.max):
return None

error = _(
u"error_datetime_after_max",
default=u"${name} is after ${max_date}, please correct.",
mapping={
"name": self.get_label(instance),
"max_date": self.localize(self.max, instance)
}
)

field_name = self.getName()
errors[field_name] = translate(error, context=api.get_request())
return errors[field_name]

def is_true(self, val):
"""Returns whether val evaluates to True
"""
val = str(val).strip().lower()
return val in ["y", "yes", "1", "true", "on"]

def get_label(self, instance):
"""Returns the translated label of this field for the given instance
"""
request = api.get_request()
label = self.widget.Label(instance)
if isinstance(label, Message):
return translate(label, context=request)
return label

def localize(self, dt, instance):
"""Returns the dt to localized time
"""
request = api.get_request()
return dtime.to_localized_time(dt, long_format=self.show_time,
context=instance, request=request)

@property
def min(self):
"""Returns the minimum datetime supported by this field
"""
no_past = getattr(self.widget, WIDGET_NOPAST, False)
if self.is_true(no_past):
return dtime.datetime.now()
return dtime.datetime.min

@property
def max(self):
"""Returns the maximum datetime supported for this field
"""
no_future = getattr(self.widget, WIDGET_NOFUTURE, False)
if self.is_true(no_future):
return dtime.datetime.now()
return dtime.datetime.max

@property
def show_time(self):
"""Returns whether the time is displayed by the widget
"""
show_time = getattr(self.widget, WIDGET_SHOWTIME, False)
return self.is_true(show_time)


InitializeClass(DateTimeField)

Expand Down
64 changes: 64 additions & 0 deletions src/senaite/core/tests/doctests/API_datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -555,3 +555,67 @@ string (YYYYMMDD[HHMMSS]

>>> dtime.to_DT(dt) is None
True

We can also the other way round conversion. Simply giving a date in ant valid
string format:

>>> dt = "1989-12-01"
>>> dtime.to_ansi(dt, show_time=False)
'19891201'

>>> dtime.to_ansi(dt, show_time=True)
'19891201000000'

>>> dt = "19891201"
>>> dtime.to_ansi(dt, show_time=False)
'19891201'

>>> dtime.to_ansi(dt, show_time=True)
'19891201000000'

Or using datetime or DateTime as the input parameter:

>>> dt = "19891201131405"
>>> dt = dtime.ansi_to_dt(dt)
>>> dtime.to_ansi(dt, show_time=False)
'19891201'

>>> dtime.to_ansi(dt, show_time=True)
'19891201131405'

>>> DT = dtime.to_DT(dt)
>>> dtime.to_ansi(DT, show_time=False)
'19891201'

>>> dtime.to_ansi(DT, show_time=True)
'19891201131405'

We even suport dates that are long before epoch:

>>> min_date = dtime.datetime.min
>>> min_date
datetime.datetime(1, 1, 1, 0, 0)

>>> dtime.to_ansi(min_date)
'00010101000000'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, got it. Therefore you format the date manually:

In [9]: dt = datetime.strptime("1000-01-01", "%Y-%m-%d")

In [10]: dt.strftime("%Y%m%d%H%M")
ValueError: year=1000 is before 1900; the datetime strftime() methods require year >= 1900


Or long after epoch:

>>> max_date = dtime.datetime.max
>>> max_date
datetime.datetime(9999, 12, 31, 23, 59, 59, 999999)

>>> dtime.to_ansi(max_date)
'99991231235959'

Still, invalid dates return None:

>>> # Month 13
>>> dt = "17891301132505"
>>> dtime.to_ansi(dt) is None
True

>>> # Month 2, day 30
>>> dt = "20030230123408"
>>> dtime.to_ansi(dt) is None
True