diff --git a/CHANGES.rst b/CHANGES.rst
index 8736701ab1..6cb4407cda 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,7 @@ Changelog
2.6.0 (unreleased)
------------------
+- #2624 Added "Maximum holding time" setting to services and analyses
- #2637 Do not remove inactive services from profiles and templates
- #2642 Fix Attribute Error in Upgrade Step 2619
- #2641 Fix AttributeError on rejection of samples without a contact set
diff --git a/src/bika/lims/browser/analyses/view.py b/src/bika/lims/browser/analyses/view.py
index bc95329e31..908d1da84a 100644
--- a/src/bika/lims/browser/analyses/view.py
+++ b/src/bika/lims/browser/analyses/view.py
@@ -23,6 +23,7 @@
from copy import copy
from copy import deepcopy
from datetime import datetime
+from datetime import timedelta
from operator import itemgetter
from bika.lims import api
@@ -38,9 +39,11 @@
from bika.lims.interfaces import IFieldIcons
from bika.lims.interfaces import IReferenceAnalysis
from bika.lims.interfaces import IRoutineAnalysis
+from bika.lims.interfaces import ISubmitted
from bika.lims.utils import check_permission
from bika.lims.utils import format_supsub
from bika.lims.utils import formatDecimalMark
+from bika.lims.utils import get_fas_ico
from bika.lims.utils import get_image
from bika.lims.utils import get_link
from bika.lims.utils import get_link_for
@@ -790,6 +793,8 @@ def folderitem(self, obj, item, index):
self._folder_item_remarks(obj, item)
# Renders the analysis conditions
self._folder_item_conditions(obj, item)
+ # Fill maximum holding time warnings
+ self._folder_item_holding_time(obj, item)
return item
@@ -1698,6 +1703,70 @@ def to_str(condition):
service = item["replace"].get("Service") or item["Service"]
item["replace"]["Service"] = "
".join([service, conditions])
+ def _folder_item_holding_time(self, analysis_brain, item):
+ """Adds an icon to the item dictionary if no result has been submitted
+ for the analysis and the holding time has passed or is about to expire.
+ It also displays the icon if the result was recorded after the holding
+ time limit.
+ """
+ analysis = self.get_object(analysis_brain)
+ if not IRoutineAnalysis.providedBy(analysis):
+ return
+
+ # get the maximum holding time for this analysis
+ max_holding_time = analysis.getMaxHoldingTime()
+ if not max_holding_time:
+ return
+
+ # get the datetime from which the max holding time is computed
+ start_date = analysis.getDateSampled()
+ start_date = dtime.to_dt(start_date)
+ if not start_date:
+ return
+
+ # get the timezone of the start date for correct comparisons
+ timezone = dtime.get_timezone(start_date)
+
+ # calculate the maximum holding date
+ delta = timedelta(minutes=api.to_minutes(**max_holding_time))
+ max_holding_date = dtime.to_ansi(start_date + delta)
+
+ # maybe the result was captured past the holding time
+ if ISubmitted.providedBy(analysis):
+ captured = analysis.getResultCaptureDate()
+ captured = dtime.to_ansi(captured, timezone=timezone)
+ if captured > max_holding_date:
+ msg = _("The result was captured past the holding time limit.")
+ icon = get_fas_ico("exclamation-triangle",
+ css_class="text-danger",
+ title=t(msg))
+ self._append_html_element(item, "ResultCaptureDate", icon)
+ return
+
+ # not yet submitted, maybe the holding time expired
+ now = dtime.to_ansi(dtime.now(), timezone=timezone)
+ if now > max_holding_date:
+ msg = _("The holding time for this sample and analysis has "
+ "expired. Proceeding with the analysis may compromise the "
+ "reliability of the results.")
+ icon = get_fas_ico("exclamation-triangle",
+ css_class="text-danger",
+ title=t(msg))
+ self._append_html_element(item, "ResultCaptureDate", icon)
+ return
+
+ # or maybe is about to expire
+ soon = dtime.to_ansi(dtime.now(), timezone=timezone)
+ if soon > max_holding_date:
+ msg = _("The holding time for this sample and analysis is about "
+ "to expire. Please complete the analysis as soon as "
+ "possible to ensure data accuracy and reliability.")
+ icon = get_fas_ico("exclamation-triangle",
+ css_class="text-warning",
+ title=t(msg))
+ self._append_html_element(item, "ResultCaptureDate", icon)
+ return
+
def is_method_required(self, analysis):
"""Returns whether the render of the selection list with methods is
required for the method passed-in, even if only option "None" is
diff --git a/src/bika/lims/browser/analysisrequest/add2.py b/src/bika/lims/browser/analysisrequest/add2.py
index f3b0a65aab..c6bed2902e 100644
--- a/src/bika/lims/browser/analysisrequest/add2.py
+++ b/src/bika/lims/browser/analysisrequest/add2.py
@@ -21,6 +21,7 @@
import json
from collections import OrderedDict
from datetime import datetime
+from datetime import timedelta
import six
import transaction
@@ -50,7 +51,9 @@
from Products.CMFPlone.utils import safe_unicode
from Products.Five.browser import BrowserView
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from senaite.core.api import dtime
from senaite.core.catalog import CONTACT_CATALOG
+from senaite.core.catalog import SETUP_CATALOG
from senaite.core.p3compat import cmp
from senaite.core.permissions import TransitionMultiResults
from zope.annotation.interfaces import IAnnotations
@@ -908,6 +911,7 @@ def get_service_info(self, obj):
"category": obj.getCategoryTitle(),
"poc": obj.getPointOfCapture(),
"conditions": self.get_conditions_info(obj),
+ "max_holding_time": obj.getMaxHoldingTime(),
})
dependencies = get_calculation_dependencies_for(obj).values()
@@ -1217,11 +1221,75 @@ def ajax_recalculate_records(self):
dependencies = self.get_unmet_dependencies_info(metadata)
metadata.update(dependencies)
+ # services conducted beyond the holding time limit
+ beyond = self.get_services_beyond_holding_time(record)
+ metadata["beyond_holding_time"] = beyond
+
# Set the metadata for current sample number (column)
out[num_sample] = metadata
return out
+ @viewcache.memoize
+ def get_services_max_holding_time(self):
+ """Returns a dict where the key is the uid of active services and the
+ value is a dict representing the maximum holding time in days, hours
+ and minutes. The dictionary only contains uids for services that have
+ a valid maximum holding time set
+ """
+ services = {}
+ query = {
+ "portal_type": "AnalysisService",
+ "point_of_capture": "lab",
+ "is_active": True,
+ }
+ brains = api.search(query, SETUP_CATALOG)
+ for brain in brains:
+ obj = api.get_object(brain)
+ max_holding_time = obj.getMaxHoldingTime()
+ if max_holding_time:
+ uid = api.get_uid(brain)
+ services[uid] = max_holding_time.copy()
+
+ return services
+
+ def get_services_beyond_holding_time(self, record):
+ """Return a list with the uids of the services that cannot be selected
+ because would be conducted past the holding time limit
+ """
+ # get the date to start count from
+ start_date = self.get_start_holding_date(record)
+ if not start_date:
+ return []
+
+ # get the timezone of the start date for correct comparisons
+ tz = dtime.get_timezone(start_date)
+
+ uids = []
+
+ # get the max holding times grouped by service uid
+ services = self.get_services_max_holding_time()
+ for uid, max_holding_time in services.items():
+
+ # calculate the maximum holding date
+ delta = timedelta(minutes=api.to_minutes(**max_holding_time))
+ max_holding_date = start_date + delta
+
+ # TypeError: can't compare offset-naive and offset-aware datetimes
+ max_date = dtime.to_ansi(max_holding_date)
+ now = dtime.to_ansi(dtime.now(), timezone=tz)
+ if now > max_date:
+ uids.append(uid)
+
+ return uids
+
+ def get_start_holding_date(self, record):
+ """Returns the datetime used to calculate the holding time limit,
+ typically the sample collection date.
+ """
+ sampled = record.get("DateSampled")
+ return dtime.to_dt(sampled)
+
def get_record_metadata(self, record):
"""Returns the metadata for the record passed in
"""
diff --git a/src/bika/lims/browser/analysisrequest/templates/ar_add2.pt b/src/bika/lims/browser/analysisrequest/templates/ar_add2.pt
index 52fc4abbeb..f0ac4ff9de 100644
--- a/src/bika/lims/browser/analysisrequest/templates/ar_add2.pt
+++ b/src/bika/lims/browser/analysisrequest/templates/ar_add2.pt
@@ -542,6 +542,16 @@
🔒
+
+
+ 🚫
+
+
diff --git a/src/bika/lims/content/abstractbaseanalysis.py b/src/bika/lims/content/abstractbaseanalysis.py
index 87846b288e..58e96a75e1 100644
--- a/src/bika/lims/content/abstractbaseanalysis.py
+++ b/src/bika/lims/content/abstractbaseanalysis.py
@@ -19,6 +19,7 @@
# 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
@@ -365,6 +366,30 @@
)
)
+MaxHoldingTime = DurationField(
+ 'MaxHoldingTime',
+ schemata="Analysis",
+ widget=DurationWidget(
+ label=_(
+ u"label_analysis_maxholdingtime",
+ default=u"Maximum holding time"
+ ),
+ description=_(
+ u"description_analysis_maxholdingtime",
+ default=u"This service will not appear for selection on the "
+ u"sample registration form if the elapsed time since "
+ u"sample collection exceeds the holding time limit. "
+ u"Exceeding this time limit may result in unreliable or "
+ u"compromised data, as the integrity of the sample can "
+ u"degrade over time. Consequently, any results obtained "
+ u"after this period may not accurately reflect the "
+ u"sample's true composition, impacting data validity. "
+ u"Note: This setting does not affect the test's "
+ u"availability in the 'Manage Analyses' view."
+ )
+ )
+)
+
# The amount of difference allowed between this analysis, and any duplicates.
DuplicateVariation = FixedPointField(
'DuplicateVariation',
@@ -789,6 +814,7 @@
Instrument,
Method,
MaxTimeAllowed,
+ MaxHoldingTime,
DuplicateVariation,
Accredited,
PointOfCapture,
@@ -1079,6 +1105,20 @@ def getMaxTimeAllowed(self):
tat = self.Schema().getField("MaxTimeAllowed").get(self)
return tat or self.bika_setup.getDefaultTurnaroundTime()
+ @security.public
+ def getMaxHoldingTime(self):
+ """Returns the maximum time since it the sample was collected for this
+ test/service to become available on sample creation. Returns None if no
+ positive maximum hold time is set. Otherwise, returns a dict with the
+ keys "days", "hours" and "minutes"
+ """
+ max_hold_time = self.Schema().getField("MaxHoldingTime").get(self)
+ if not max_hold_time:
+ return {}
+ if api.to_minutes(**max_hold_time) <= 0:
+ return {}
+ return max_hold_time
+
# TODO Remove. ResultOptionsType field was replaced by ResulType field
def getResultOptionsType(self):
if self.getStringResult():
diff --git a/src/bika/lims/utils/__init__.py b/src/bika/lims/utils/__init__.py
index 5498412ad2..1045f5bc1d 100644
--- a/src/bika/lims/utils/__init__.py
+++ b/src/bika/lims/utils/__init__.py
@@ -915,3 +915,13 @@ def get_client(obj):
return obj.getClient()
return None
+
+
+def get_fas_ico(icon_id, **attrs):
+ """Returns a well-formed fontawesome (fas) icon
+ """
+ css_class = attrs.pop("css_class", "")
+ if not icon_id.startswith("fa-"):
+ icon_id = "fa-%s" % icon_id
+ attrs["css_class"] = " ".join([css_class, "fas", icon_id]).strip()
+ return "" % render_html_attributes(**attrs)
diff --git a/src/senaite/core/api/dtime.py b/src/senaite/core/api/dtime.py
index 6169ecc838..161ed6e1f3 100644
--- a/src/senaite/core/api/dtime.py
+++ b/src/senaite/core/api/dtime.py
@@ -42,6 +42,8 @@
from zope.i18n import translate
+RX_GMT = r"^(\bGMT\b|)([+-]?)(\d{1,2})"
+
_marker = object()
@@ -156,7 +158,8 @@ def to_DT(dt):
return None
elif is_dt(dt):
try:
- # XXX Why do this instead of DateTime(dt)?
+ # We do this because isoformat() comes with the +/- utc offset at
+ # the end, so it becomes easier than having to rely on tzinfo stuff
return DateTime(dt.isoformat())
except DateTimeError:
return DateTime(dt)
@@ -191,6 +194,15 @@ def to_dt(dt):
return None
+def now():
+ """Returns a timezone-aware datetime representing current date and time
+ as defined in Zope's TZ environment variable or, if not set, from OS
+
+ :returns: timezone-aware datetime object
+ """
+ return to_dt(DateTime())
+
+
def ansi_to_dt(dt):
"""The YYYYMMDD format is defined by ANSI X3.30. Therefore, 2 December 1,
1989 would be represented as 19891201. When times are transmitted, they
@@ -211,16 +223,44 @@ def ansi_to_dt(dt):
return datetime.strptime(dt, date_format)
-def to_ansi(dt, show_time=True):
+def to_ansi(dt, show_time=True, timezone=None):
"""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
+ :param timezone: if set, converts the date to the given timezone
:returns: str that represents the datetime in ANSI format
"""
dt = to_dt(dt)
if dt is None:
return None
+ if timezone and is_valid_timezone(timezone):
+ if is_timezone_naive(dt):
+ # XXX Localize to default TZ to overcome `to_dt` inconsistency:
+ #
+ # - if the input value is a datetime/date type, `to_dt` does not
+ # convert to the OS's zone, even if the value is tz-naive.
+ # - if the input value is a str or DateTime, `to_dt` does convert
+ # to the system default zone.
+ #
+ # For instance:
+ # >>> to_dt("19891201131405")
+ # datetime.datetime(1989, 12, 1, 13, 14, 5, tzinfo=)
+ # >>> ansi_to_dt("19891201131405")
+ # datetime.datetime(1989, 12, 1, 13, 14, 5)
+ #
+ # That causes the following inconsistency:
+ # >>> to_ansi(to_dt("19891201131405"), timezone="Pacific/Fiji")
+ # 19891202011405
+ # >>> to_ansi(ansi_to_dt("19891201131405"), timezone="Pacific/Fiji")
+ # 19891201131405
+
+ default_tz = get_timezone(dt)
+ default_zone = pytz.timezone(default_tz)
+ dt = default_zone.localize(dt)
+
+ dt = to_zone(dt, timezone)
+
ansi = "{:04d}{:02d}{:02d}".format(dt.year, dt.month, dt.day)
if not show_time:
return ansi
@@ -244,14 +284,24 @@ def get_timezone(dt, default="Etc/GMT"):
if tz:
# convert DateTime `GMT` to `Etc/GMT` timezones
# NOTE: `GMT+1` get `Etc/GMT-1`!
- if tz.startswith("GMT+0"):
- tz = tz.replace("GMT+0", "Etc/GMT")
- elif tz.startswith("GMT+"):
- tz = tz.replace("GMT+", "Etc/GMT-")
- elif tz.startswith("GMT-"):
- tz = tz.replace("GMT-", "Etc/GMT+")
- elif tz.startswith("GMT"):
- tz = tz.replace("GMT", "Etc/GMT")
+ #
+ # The special area of "Etc" is used for some administrative zones,
+ # particularly for "Etc/UTC" which represents Coordinated Universal
+ # Time. In order to conform with the POSIX style, those zone names
+ # beginning with "Etc/GMT" have their sign reversed from the standard
+ # ISO 8601 convention. In the "Etc" area, zones west of GMT have a
+ # positive sign and those east have a negative sign in their name (e.g
+ # "Etc/GMT-14" is 14 hours ahead of GMT).
+ # --- From https://en.wikipedia.org/wiki/Tz_database#Area
+ match = re.match(RX_GMT, tz)
+ if match:
+ groups = match.groups()
+ hours = to_int(groups[2], 0)
+ if not hours:
+ return "Etc/GMT"
+
+ offset = "-" if groups[1] == "+" else "+"
+ tz = "Etc/GMT%s%s" % (offset, hours)
else:
tz = default
diff --git a/src/senaite/core/browser/static/js/bika.lims.analysisrequest.add.js b/src/senaite/core/browser/static/js/bika.lims.analysisrequest.add.js
index 36f7f32085..a08a8fbe34 100644
--- a/src/senaite/core/browser/static/js/bika.lims.analysisrequest.add.js
+++ b/src/senaite/core/browser/static/js/bika.lims.analysisrequest.add.js
@@ -520,6 +520,8 @@
$("body").on("select", "tr[fieldname=Profiles] textarea", this.on_analysis_profile_selected);
// Analysis Profile deselected
$("body").on("deselect", "tr[fieldname=Profiles] textarea", this.on_analysis_profile_removed);
+ // Date sampled changed
+ $("body").on("change", "tr[fieldname=DateSampled] input", this.recalculate_records);
// Save button clicked
$("body").on("click", "[name='save_button']", this.on_form_submit);
// Save and copy button clicked
@@ -599,8 +601,14 @@
var me;
console.debug("*** update_form ***");
me = this;
- // initially hide all lock icons
+ // initially hide all service-related icons
$(".service-lockbtn").hide();
+ // hide all holding time related icons and set checks enabled by default
+ $(".analysisservice").show();
+ $(".service-beyondholdingtime").hide();
+ $(".analysisservice-cb").prop({
+ "disabled": false
+ });
// set all values for one record (a single column in the AR Add form)
return $.each(records, function(arnum, record) {
// Apply the values generically
@@ -619,7 +627,10 @@
lock = $(`#${uid}-${arnum}-lockbtn`);
// service is included in a profile
if (uid in record.service_to_profiles) {
- lock.show();
+ // do not display the lock button if beyond holding time
+ if (indexOf.call(record.beyond_holding_time, uid) < 0) {
+ lock.show();
+ }
}
// select the service
return me.set_service(arnum, uid, true);
@@ -629,7 +640,7 @@
return me.set_template(arnum, template);
});
// handle unmet dependencies, one at a time
- return $.each(record.unmet_dependencies, function(uid, dependencies) {
+ $.each(record.unmet_dependencies, function(uid, dependencies) {
var context, dialog, service;
service = record.service_metadata[uid];
context = {
@@ -654,6 +665,21 @@
// break the iteration after the first loop to avoid multiple dialogs.
return false;
});
+ // disable (and uncheck) services that are beyond sample holding time
+ return $.each(record.beyond_holding_time, function(index, uid) {
+ var beyond_holding_time, parent, service_cb;
+ // display the alert
+ beyond_holding_time = $(`#${uid}-${arnum}-beyondholdingtime`);
+ beyond_holding_time.show();
+ // disable the service's checkbox to prevent value submit
+ service_cb = $(`#cb_${arnum}_${uid}`);
+ service_cb.prop({
+ "disabled": true
+ });
+ // hide checkbox container
+ parent = service_cb.parent("div.analysisservice");
+ return parent.hide();
+ });
});
}
diff --git a/src/senaite/core/browser/static/js/coffee/bika.lims.analysisrequest.add.coffee b/src/senaite/core/browser/static/js/coffee/bika.lims.analysisrequest.add.coffee
index f8917e5700..dc9c45e74f 100644
--- a/src/senaite/core/browser/static/js/coffee/bika.lims.analysisrequest.add.coffee
+++ b/src/senaite/core/browser/static/js/coffee/bika.lims.analysisrequest.add.coffee
@@ -270,6 +270,9 @@ class window.AnalysisRequestAdd
# Analysis Profile deselected
$("body").on "deselect", "tr[fieldname=Profiles] textarea", @on_analysis_profile_removed
+ # Date sampled changed
+ $("body").on "change", "tr[fieldname=DateSampled] input", @recalculate_records
+
# Save button clicked
$("body").on "click", "[name='save_button']", @on_form_submit
# Save and copy button clicked
@@ -368,9 +371,14 @@ class window.AnalysisRequestAdd
me = this
- # initially hide all lock icons
+ # initially hide all service-related icons
$(".service-lockbtn").hide()
+ # hide all holding time related icons and set checks enabled by default
+ $(".analysisservice").show()
+ $(".service-beyondholdingtime").hide()
+ $(".analysisservice-cb").prop "disabled": no
+
# set all values for one record (a single column in the AR Add form)
$.each records, (arnum, record) ->
@@ -388,7 +396,9 @@ class window.AnalysisRequestAdd
lock = $("##{uid}-#{arnum}-lockbtn")
# service is included in a profile
if uid of record.service_to_profiles
- lock.show()
+ # do not display the lock button if beyond holding time
+ if uid not in record.beyond_holding_time
+ lock.show()
# select the service
me.set_service arnum, uid, yes
@@ -422,6 +432,19 @@ class window.AnalysisRequestAdd
# break the iteration after the first loop to avoid multiple dialogs.
return false
+ # disable (and uncheck) services that are beyond sample holding time
+ $.each record.beyond_holding_time, (index, uid) ->
+ # display the alert
+ beyond_holding_time = $("##{uid}-#{arnum}-beyondholdingtime")
+ beyond_holding_time.show()
+
+ # disable the service's checkbox to prevent value submit
+ service_cb = $("#cb_#{arnum}_#{uid}")
+ service_cb.prop "disabled": yes
+
+ # hide checkbox container
+ parent = service_cb.parent "div.analysisservice"
+ parent.hide()
###*
* Return the portal url (calculated in code)
diff --git a/src/senaite/core/tests/doctests/API_datetime.rst b/src/senaite/core/tests/doctests/API_datetime.rst
index 052ae125f1..797c1e4cd6 100644
--- a/src/senaite/core/tests/doctests/API_datetime.rst
+++ b/src/senaite/core/tests/doctests/API_datetime.rst
@@ -309,6 +309,31 @@ Get the timezone from `datetime.date` objects:
>>> dtime.get_timezone(dt.date)
'Etc/GMT'
+For consistency reasons, `GMT` timezones are always converted to `Etc/GMT`:
+
+ >>> DT = DateTime('2024/11/06 15:11:20.956914 GMT+1')
+ >>> DT.timezone()
+ 'GMT+1'
+
+ >>> dtime.get_timezone(DT)
+ 'Etc/GMT-1'
+
+Even for `datetime` objects:
+
+ >>> dt = DT.asdatetime()
+ >>> dt.tzname()
+ 'GMT+0100'
+
+ >>> dtime.get_timezone(dt)
+ 'Etc/GMT-1'
+
+ >>> dt = dtime.to_dt(DT)
+ >>> dt.tzname()
+ '+01'
+
+ >>> dtime.get_timezone(dt)
+ 'Etc/GMT-1'
+
We can even get the obsolete timezone that was applying to an old date:
>>> old_dt = datetime(1682, 8, 16, 2, 44, 54)
@@ -458,6 +483,34 @@ Convert `DateTime` objects to a timezone:
DateTime('1970/01/01 02:00:00 GMT+1')
+Get the current datetime (with timezone)
+........................................
+
+Python's `datetime.now()` returns a timezone-naive datetime object, whereas
+Zope's DateTime() returns a timezone-aware DateTime object. This difference
+can lead to inconsistencies when converting and comparing dates if not
+carefully managed. The `dtime.now(timezone)` function provides the current
+datetime with the timezone defined in Zope's TZ environment variable, like
+Zope's `DateTime()` does. This function is strongly recommended over
+`datetime.now()` except in cases where a timezone-naive datetime is explicitly
+needed.
+
+ >>> now_dt = dtime.now()
+ >>> now_DT = DateTime()
+ >>> now_dt.utcoffset().seconds == now_DT.tzoffset()
+ True
+
+ >>> ansi = dtime.to_ansi(now_dt)
+ >>> dtime.to_ansi(now_DT) == ansi
+ True
+
+ >>> dtime.to_ansi(dtime.to_dt(now_DT)) == ansi
+ True
+
+ >>> dtime.to_ansi(dtime.to_DT(now_dt)) == ansi
+ True
+
+
Make a POSIX timestamp
......................
@@ -759,6 +812,48 @@ Still, invalid dates return None:
>>> dtime.to_ansi(dt) is None
True
+We can also specify the timezone. Since `to_ansi` relies on `to_dt` to convert
+the input value to a valid datetime, naive datetime is localized to OS`s
+default timezone before the hours shift:
+
+ >>> dtime.to_ansi("1989-12-01")
+ '19891201000000'
+
+ >>> dtime.to_ansi("1989-12-01", timezone="Pacific/Fiji")
+ '19891201120000'
+
+ >>> dt = dtime.to_dt("19891201131405")
+ >>> dtime.to_ansi(dt)
+ '19891201131405'
+
+ >>> dtime.to_ansi(dt, timezone="Pacific/Fiji")
+ '19891202011405'
+
+ >>> dt = dtime.ansi_to_dt("19891201131405")
+ >>> dtime.to_ansi(dt)
+ '19891201131405'
+
+ >>> dtime.to_ansi(dt, timezone="Pacific/Fiji")
+ '19891202011405'
+
+The system does the shift if the date comes with a valid timezone:
+
+ >>> dt = dtime.ansi_to_dt("19891201131405")
+ >>> dt = dtime.to_zone(dt, "Pacific/Fiji")
+ >>> dtime.to_ansi(dt, timezone="Pacific/Fiji")
+ '19891201131405'
+
+ >>> dtime.to_ansi(dt, timezone="Etc/GMT")
+ '19891201011405'
+
+If the timezone is not valid, the system returns in ANSI without shifts:
+
+ >>> dtime.to_ansi(dt, timezone="+03")
+ '19891201131405'
+
+ >>> dtime.to_ansi(dt, timezone="Mars")
+ '19891201131405'
+
Relative delta between two dates
................................