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 ................................