Skip to content

Commit

Permalink
Add support for dates before 1900 (senaite#2090)
Browse files Browse the repository at this point in the history
* Catch ValueError in datetimewidget

Traceback:
```
Traceback (innermost last):
  Module ZServer.ZPublisher.Publish, line 144, in publish
  Module ZPublisher.mapply, line 85, in mapply
  Module ZServer.ZPublisher.Publish, line 44, in call_object
  Module bika.lims.browser.analysisrequest.view, line 42, in __call__
  Module bika.lims.browser.header_table, line 99, in __call__
  Module Products.Five.browser.pagetemplatefile, line 126, in __call__
  Module Products.Five.browser.pagetemplatefile, line 61, in __call__
  Module zope.pagetemplate.pagetemplate, line 135, in pt_render
  Module Products.PageTemplates.engine, line 378, in __call__
  Module z3c.pt.pagetemplate, line 176, in render
  Module chameleon.zpt.template, line 302, in render
  Module chameleon.template, line 215, in render
  Module chameleon.template, line 192, in render
  Module b5af7af227d0614ddb683e49ef7fbcf2, line 576, in render
  Module f84d498475cc404181368cde9fa956bc, line 379, in render_edit
  Module d88bffbebbf4e01f15a333556ab23c2e, line 702, in render_edit
  Module f84d498475cc404181368cde9fa956bc, line 323, in __fill_widget_body
  Module zope.tales.pythonexpr, line 73, in __call__
   - __traceback_info__: (widget.get_time(value) if value else '')
  Module <string>, line 1, in <module>
  Module senaite.core.browser.widgets.datetimewidget, line 88, in get_time
  Module DateTime.DateTime, line 1566, in strftime
ValueError: year=1111 is before 1900; the datetime strftime() methods require year >= 1900
```

* Handle dates before 1900 gracefully in dtime API

* Support date before 1900 in DX datewidget

* Support date before 1900 in searchable text tokens

* Support dates < 1900 in samples view

* Moved to_localized_time to date API

* Pad a zero if value is a single digit

* Changelog updated

* Fix tests and added some more

* Make flake8 happy

* Removed unused imports

* Ensure DateTime object when a string was passed in

Co-authored-by: Jordi Puiggené <jp@naralabs.com>
  • Loading branch information
ramonski and xispa authored Aug 2, 2022
1 parent 1da8e42 commit ac82000
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 50 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Changelog
2.3.0 (unreleased)
------------------

- #2090 Add support for dates before 1900
- #2089 Fix LDL/UDL cut-off and exponential float conversion
- #2078 Replace dynamic code execution with dynamic import in reports
- #2083 Lookup workflow action redirect URL from request first
Expand Down
3 changes: 2 additions & 1 deletion src/bika/lims/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1400,7 +1400,8 @@ def to_searchable_text_metadata(value):
if isinstance(value, dict):
return to_searchable_text_metadata(value.values())
if is_date(value):
return value.strftime("%Y-%m-%d")
from senaite.core.api.dtime import date_to_string
return date_to_string(value, "%Y-%m-%d")
if is_at_content(value):
return to_searchable_text_metadata(get_title(value))
if not isinstance(value, six.string_types):
Expand Down
41 changes: 1 addition & 40 deletions src/bika/lims/browser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,56 +18,17 @@
# Copyright 2018-2021 by it's authors.
# Some rights reserved, see README and LICENSE.

import traceback

from AccessControl import ClassSecurityInfo
from bika.lims import api
from bika.lims import logger
from DateTime.DateTime import DateTime
from Products.CMFCore.utils import getToolByName
from Products.CMFPlone.i18nl10n import ulocalized_time as _ut
from Products.Five.browser import BrowserView as BaseBrowserView
from senaite.core.api.dtime import to_localized_time as ulocalized_time
from zope.cachedescriptors.property import Lazy as lazy_property
from zope.i18n import translate


def ulocalized_time(time, long_format=None, time_only=None, context=None,
request=None):
"""
This function gets ans string as time or a DateTime objects and returns a
string with the time formatted
:param time: The time to process
:type time: str/DateTime
:param long_format: If True, return time in ling format
:type portal_type: boolean/null
:param time_only: If True, only returns time.
:type title: boolean/null
:param context: The current context
:type context: ATContentType
:param request: The current request
:type request: HTTPRequest object
:returns: The formatted date as string
:rtype: string
"""
# if time is a string, we'll try pass it through strptime with the various
# formats defined.
time = api.to_date(time)
if not time or not isinstance(time, DateTime):
return ''

try:
time_str = _ut(time, long_format, time_only, context, 'senaite.core', request)
except ValueError:
err_msg = traceback.format_exc() + '\n'
logger.warn(
err_msg + '\n' +
"Error converting '{}' time to string in {}."
.format(time, context))
time_str = ''
return time_str


class BrowserView(BaseBrowserView):
security = ClassSecurityInfo()

Expand Down
1 change: 0 additions & 1 deletion src/bika/lims/content/abstractbaseanalysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
# Some rights reserved, see README and LICENSE.

from AccessControl import ClassSecurityInfo
from bika.lims import api
from bika.lims import bikaMessageFactory as _
from bika.lims.browser.fields import DurationField
from bika.lims.browser.fields import UIDReferenceField
Expand Down
107 changes: 107 additions & 0 deletions src/senaite/core/api/dtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import time
from datetime import date
from datetime import datetime
from string import Template

import six

Expand All @@ -14,6 +15,8 @@
from DateTime.DateTime import DateError
from DateTime.DateTime import SyntaxError
from DateTime.DateTime import TimeError
from Products.CMFPlone.i18nl10n import ulocalized_time
from zope.i18n import translate


def is_str(obj):
Expand Down Expand Up @@ -269,3 +272,107 @@ def to_iso_format(dt):
DT = to_DT(dt)
return to_iso_format(DT)
return None


def date_to_string(dt, fmt="%Y-%m-%d", default=""):
"""Format the date to string
"""
if not is_date(dt):
return default

# NOTE: The function `is_date` evaluates also string dates as `True`.
# We ensure in such a case to have a `DateTime` object and leave
# possible `datetime` objects unchanged.
if isinstance(dt, six.string_types):
dt = to_DT(dt)

try:
return dt.strftime(fmt)
except ValueError:
# Fix ValueError: year=1111 is before 1900;
# the datetime strftime() methods require year >= 1900

# convert format string to be something like "${Y}-${m}-${d}"
new_fmt = ""
var = False
for x in fmt:
if x == "%":
var = True
new_fmt += "${"
continue
if var:
new_fmt += x
new_fmt += "}"
var = False
else:
new_fmt += x

def pad(val):
"""Add a zero if val is a single digit
"""
return "{:0>2}".format(val)

# Manually extract relevant date and time parts
dt = to_DT(dt)
data = {
"Y": dt.year(),
"y": dt.yy(),
"m": dt.mm(),
"d": dt.dd(),
"H": pad(dt.h_24()),
"I": pad(dt.h_12()),
"M": pad(dt.minute()),
"p": dt.ampm().upper(),
"S": dt.second(),
}

return Template(new_fmt).safe_substitute(data)


def to_localized_time(dt, long_format=None, time_only=None,
context=None, request=None, default=""):
"""Convert a date object to a localized string
:param dt: The date/time to localize
:type dt: str/datetime/DateTime
:param long_format: Return long date/time if True
:type portal_type: boolean/null
:param time_only: If True, only returns time.
:type title: boolean/null
:param context: The current context
:type context: ATContentType
:param request: The current request
:type request: HTTPRequest object
:returns: The formatted date as string
:rtype: string
"""
dt = to_DT(dt)
if not dt:
return default

try:
time_str = ulocalized_time(
dt, long_format, time_only, context, "senaite.core", request)
except ValueError:
# Handle dates < 1900

# code taken from Products.CMFPlone.i18nl110n.ulocalized_time
if time_only:
msgid = "time_format"
elif long_format:
msgid = "date_format_long"
else:
msgid = "date_format_short"

formatstring = translate(msgid, "senaite.core", {}, request)
if formatstring == msgid:
if msgid == "date_format_long":
formatstring = "%Y-%m-%d %H:%M" # 2038-01-19 03:14
elif msgid == "date_format_short":
formatstring = "%Y-%m-%d" # 2038-01-19
elif msgid == "time_format":
formatstring = "%H:%M" # 03:14
else:
formatstring = "[INTERNAL ERROR]"
time_str = date_to_string(dt, formatstring)
return time_str
3 changes: 2 additions & 1 deletion src/senaite/core/browser/samples/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from bika.lims.utils import t
from DateTime import DateTime
from senaite.app.listing import ListingView
from senaite.core.api import dtime
from senaite.core.catalog import SAMPLE_CATALOG
from senaite.core.interfaces import ISamples
from senaite.core.interfaces import ISamplesView
Expand Down Expand Up @@ -761,7 +762,7 @@ def to_datetime_input_value(self, date):
"""
if not isinstance(date, DateTime):
return ""
return date.strftime("%Y-%m-%d %H:%M")
return dtime.date_to_string("%Y-%m-%d %H:%M")

def getDefaultAddCount(self):
return self.context.bika_setup.getDefaultNumberOfARsToAdd()
Expand Down
10 changes: 6 additions & 4 deletions src/senaite/core/browser/widgets/datetimewidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from Products.Archetypes.Registry import registerPropertyType
from Products.Archetypes.Registry import registerWidget
from Products.Archetypes.Widget import TypesWidget
from senaite.core.api import dtime


class DateTimeWidget(TypesWidget):
Expand Down Expand Up @@ -67,20 +68,21 @@ def to_local_date(self, time, context, request):
"""
dt = self.to_tz_date(time)
if self.show_time:
return dt.strftime("%Y-%m-%dT%H:%M")
return dt.strftime("%Y-%m-%d")
return dtime.date_to_string(dt, "%Y-%m-%dT%H:%M")
return dtime.date_to_string(dt, "%Y-%m-%d")

def get_date(self, value):
if not value:
return ""
dt = self.to_tz_date(value)
return dt.strftime("%Y-%m-%d")

return dtime.date_to_string(dt, "%Y-%m-%d")

def get_time(self, value):
if not value:
return ""
dt = self.to_tz_date(value)
return dt.strftime("%H:%M")
return dtime.date_to_string(dt, "%H:%M")

def get_max(self):
now = DateTime()
Expand Down
1 change: 0 additions & 1 deletion src/senaite/core/content/senaitesetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,3 @@ def setSiteLogoCSS(self, value):
"""
mutator = self.mutator("site_logo_css")
return mutator(self, value)

80 changes: 80 additions & 0 deletions src/senaite/core/tests/doctests/API_datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,83 @@ Convert to ISO format

>>> dtime.to_iso_format(dtime.to_DT(dt_local))
'2021-08-01T12:00:00+02:00'


Convert date to string
......................


Check with valid date:

>>> DATE = "2022-08-01 12:00"
>>> dt = datetime.strptime(DATE, DATEFORMAT)
>>> dtime.date_to_string(dt)
'2022-08-01'

>>> dtime.date_to_string(dt, fmt="%H:%M")
'12:00'

>>> dtime.date_to_string(dt, fmt="%Y-%m-%dT%H:%M")
'2022-08-01T12:00'

Check if the `ValueError: strftime() methods require year >= 1900` is handled gracefully:

>>> DATE = "1010-11-12 22:23"
>>> dt = datetime.strptime(DATE, DATEFORMAT)
>>> dtime.date_to_string(dt)
'1010-11-12'

>>> dtime.date_to_string(dt, fmt="%H:%M")
'22:23'

>>> dtime.date_to_string(dt, fmt="%Y-%m-%dT%H:%M")
'1010-11-12T22:23'

>>> dtime.date_to_string(dt, fmt="%Y-%m-%d %H:%M")
'1010-11-12 22:23'

>>> dtime.date_to_string(dt, fmt="%Y/%m/%d %H:%M")
'1010/11/12 22:23'

Check the same with `DateTime` objects:

>>> dt = datetime.strptime(DATE, DATEFORMAT)
>>> DT = dtime.to_DT(dt)
>>> dtime.date_to_string(DT)
'1010-11-12'

>>> dtime.date_to_string(DT, fmt="%H:%M")
'22:23'

>>> dtime.date_to_string(DT, fmt="%Y-%m-%dT%H:%M")
'1010-11-12T22:23'

>>> dtime.date_to_string(DT, fmt="%Y-%m-%d %H:%M")
'1010-11-12 22:23'

>>> dtime.date_to_string(DT, fmt="%Y/%m/%d %H:%M")
'1010/11/12 22:23'

Check paddings in hour/minute:

>>> DATE = "2022-08-01 01:02"
>>> dt = datetime.strptime(DATE, DATEFORMAT)
>>> dtime.date_to_string(dt, fmt="%Y-%m-%d %H:%M")
'2022-08-01 01:02'

>>> DATE = "1755-08-01 01:02"
>>> dt = datetime.strptime(DATE, DATEFORMAT)
>>> dtime.date_to_string(dt, fmt="%Y-%m-%d %H:%M")
'1755-08-01 01:02'

Check 24h vs 12h format:

>>> DATE = "2022-08-01 23:01"
>>> dt = datetime.strptime(DATE, DATEFORMAT)
>>> dtime.date_to_string(dt, fmt="%Y-%m-%d %I:%M %p")
'2022-08-01 11:01 PM'

>>> DATE = "1755-08-01 23:01"
>>> dt = datetime.strptime(DATE, DATEFORMAT)
>>> dtime.date_to_string(dt, fmt="%Y-%m-%d %I:%M %p")
'1755-08-01 11:01 PM'
4 changes: 2 additions & 2 deletions src/senaite/core/z3cform/widgets/datetimewidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ def get_date(self, value):
dt = self.to_datetime(value)
if not dt:
return u""
return dt.strftime(DATE_FORMAT)
return dtime.date_to_string(dt, DATE_FORMAT)

def get_time(self, value):
"""Return only the time part of the value
Expand All @@ -200,7 +200,7 @@ def get_time(self, value):
dt = self.to_datetime(value)
if not dt:
return u""
return dt.strftime(HOUR_FORMAT)
return dtime.date_to_string(dt, HOUR_FORMAT)

def date_now(self, offset=0):
"""Get the current date without time component
Expand Down

0 comments on commit ac82000

Please sign in to comment.