From 375d4d0a7689e8f9768024c49e54bd22a214fbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Tue, 19 Nov 2024 20:57:34 +0100 Subject: [PATCH 1/2] Do not remove inactive services from profiles and templates (#2637) * Keep inactive services in profiles * Keep inactive services in templates * Skip retrieval of service uids from profiles on create_analysis_request * Changelog * Fix doctest * Refactor the code from getServices() * Add keep_inactive parameter to setServices * getServiceUIDs --> getServicesUIDs * Revert "getServiceUIDs --> getServicesUIDs" This reverts commit 07e11815c842da901c8d95fddbc2e07f2bfa8272. * getAnalysisServiceUIDs --> getServiceUIDs * Ensure consistency between getServices and getServiceUIDs * Complete doctests --------- Co-authored-by: Ramon Bartl --- CHANGES.rst | 1 + src/bika/lims/content/analysisservice.py | 19 ----- src/bika/lims/utils/analysisrequest.py | 9 --- src/senaite/core/content/analysisprofile.py | 42 +++++++++-- src/senaite/core/content/sampletemplate.py | 48 ++++++++++-- .../core/tests/doctests/AnalysisProfile.rst | 56 ++++++++++++++ .../doctests/AnalysisServiceInactivation.rst | 73 +++++++++++++++++-- .../core/tests/doctests/SampleTemplate.rst | 60 +++++++++++++-- 8 files changed, 255 insertions(+), 53 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b2ddaf803a..8736701ab1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 2.6.0 (unreleased) ------------------ +- #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 - #2640 Fix missing custom transitions via adapter in Worksheet's analyses diff --git a/src/bika/lims/content/analysisservice.py b/src/bika/lims/content/analysisservice.py index 668a175e13..72ee0300ba 100644 --- a/src/bika/lims/content/analysisservice.py +++ b/src/bika/lims/content/analysisservice.py @@ -558,25 +558,6 @@ def _default_calculation_vocabulary(self): dlist.add("", _("None")) return dlist - def after_deactivate_transition_event(self): - """Method triggered after a 'deactivate' transition - - Removes this service from all assigned Profiles and Templates. - """ - catalog = api.get_tool(SETUP_CATALOG) - - # Remove the service from profiles to which is assigned - profiles = catalog(portal_type="AnalysisProfile") - for profile in profiles: - profile = api.get_object(profile) - profile.remove_service(self) - - # Remove the service from templates to which is assigned - templates = catalog(portal_type="SampleTemplate") - for template in templates: - template = api.get_object(template) - template.remove_service(self) - # XXX DECIDE IF NEEDED # -------------------- diff --git a/src/bika/lims/utils/analysisrequest.py b/src/bika/lims/utils/analysisrequest.py index d22af3f131..6cf11f235e 100644 --- a/src/bika/lims/utils/analysisrequest.py +++ b/src/bika/lims/utils/analysisrequest.py @@ -41,7 +41,6 @@ from bika.lims.utils import tmpID from bika.lims.workflow import doActionFor from DateTime import DateTime -from Products.Archetypes.config import UID_CATALOG from Products.Archetypes.event import ObjectInitializedEvent from Products.CMFPlone.utils import _createObjectByType from senaite.core.catalog import SETUP_CATALOG @@ -267,14 +266,6 @@ def to_list(value): # Convert them to a list of service uids uids = filter(None, map(to_service_uid, uids)) - # Extend with service uids from profiles - profiles = to_list(values.get("Profiles")) - if profiles: - uid_catalog = api.get_tool(UID_CATALOG) - for brain in uid_catalog(UID=profiles): - profile = api.get_object(brain) - uids.extend(profile.getServiceUIDs() or []) - # Get the service uids without duplicates, but preserving the order return list(OrderedDict.fromkeys(uids).keys()) diff --git a/src/senaite/core/content/analysisprofile.py b/src/senaite/core/content/analysisprofile.py index cf64f080dc..810e7a82e5 100644 --- a/src/senaite/core/content/analysisprofile.py +++ b/src/senaite/core/content/analysisprofile.py @@ -252,7 +252,7 @@ def getRawServices(self): return accessor(self) or [] @security.protected(permissions.View) - def getServices(self): + def getServices(self, active_only=True): """Returns a list of service objects >>> self.getServices() @@ -260,12 +260,14 @@ def getServices(self): :returns: List of analysis service objects """ - records = self.getRawServices() - service_uids = map(lambda r: r.get("uid"), records) - return list(map(api.get_object, service_uids)) + services = map(api.get_object, self.getRawServiceUIDs()) + if active_only: + # filter out inactive services + services = filter(api.is_active, services) + return list(services) @security.protected(permissions.ModifyPortalContent) - def setServices(self, value): + def setServices(self, value, keep_inactive=True): """Set services for the profile This method accepts either a list of analysis service objects, a list @@ -295,6 +297,18 @@ def setServices(self, value): raise TypeError( "Expected object, uid or record, got %r" % type(v)) records.append({"uid": uid, "hidden": hidden}) + + if keep_inactive: + # keep inactive services so they come up again when reactivated + uids = [record.get("uid") for record in records] + for record in self.getRawServices(): + uid = record.get("uid") + if uid in uids: + continue + obj = api.get_object(uid) + if not api.is_active(obj): + records.append(record) + mutator = self.mutator("services") mutator(self, records) @@ -302,8 +316,22 @@ def setServices(self, value): Service = Services = property(getServices, setServices) @security.protected(permissions.View) - def getServiceUIDs(self): - """Returns a list of the selected service UIDs + def getServiceUIDs(self, active_only=True): + """Returns a list of UIDs for the referenced AnalysisService objects + + :param active_only: If True, only UIDs of active services are returned + :returns: A list of unique identifiers (UIDs) + """ + if active_only: + services = self.getServices(active_only=active_only) + return list(map(api.get_uid, services)) + return self.getRawServiceUIDs() + + @security.protected(permissions.View) + def getRawServiceUIDs(self): + """Returns the list of UIDs stored as raw data in the 'Services' field + + :returns: A list of UIDs extracted from the raw 'Services' data. """ services = self.getRawServices() return list(map(lambda record: record.get("uid"), services)) diff --git a/src/senaite/core/content/sampletemplate.py b/src/senaite/core/content/sampletemplate.py index 1750bdc845..b8ec27f526 100644 --- a/src/senaite/core/content/sampletemplate.py +++ b/src/senaite/core/content/sampletemplate.py @@ -453,7 +453,7 @@ def getRawServices(self): return accessor(self) or [] @security.protected(permissions.View) - def getServices(self): + def getServices(self, active_only=True): """Returns a list of service objects >>> self.getServices() @@ -461,12 +461,14 @@ def getServices(self): :returns: List of analysis service objects """ - records = self.getRawServices() - service_uids = map(lambda r: r.get("uid"), records) - return list(map(api.get_object, service_uids)) + services = map(api.get_object, self.getRawServiceUIDs()) + if active_only: + # filter out inactive services + services = filter(api.is_active, services) + return list(services) @security.protected(permissions.ModifyPortalContent) - def setServices(self, value): + def setServices(self, value, keep_inactive=True): """Set services for the template This method accepts either a list of analysis service objects, a list @@ -508,12 +510,44 @@ def setServices(self, value): "part_id": part_id, }) + if keep_inactive: + # keep inactive services so they come up again when reactivated + uids = [record.get("uid") for record in records] + for record in self.getRawServices(): + uid = record.get("uid") + if uid in uids: + continue + obj = api.get_object(uid) + if not api.is_active(obj): + records.append(record) + mutator = self.mutator("services") mutator(self, records) # BBB: AT schema field property Services = property(getServices, setServices) + @security.protected(permissions.View) + def getServiceUIDs(self, active_only=True): + """Returns a list of UIDs for the referenced AnalysisService objects + + :param active_only: If True, only UIDs of active services are returned + :returns: A list of unique identifiers (UIDs) + """ + if active_only: + services = self.getServices(active_only=active_only) + return list(map(api.get_uid, services)) + return self.getRawServiceUIDs() + + @security.protected(permissions.View) + def getRawServiceUIDs(self): + """Returns the list of UIDs stored as raw data in the 'Services' field + + :returns: A list of UIDs extracted from the raw 'Services' data. + """ + services = self.getRawServices() + return list(map(lambda record: record.get("uid"), services)) + @deprecate("deprecated since SENAITE 2.6: Use getRawServices() instead") @security.protected(permissions.View) def getAnalysisServicesSettings(self): @@ -602,12 +636,12 @@ def getAnalysisServicePartitionID(self, service): return "" return record.get("part_id", "") + @deprecate("deprecated since SENAITE 2.6: Use getServiceUIDs() instead") @security.protected(permissions.View) def getAnalysisServiceUIDs(self): """Returns a list of all assigned service UIDs """ - services = self.getRawServices() - return list(map(lambda record: record.get("uid"), services)) + return self.getServiceUIDs() @security.protected(permissions.View) def get_services_by_uid(self): diff --git a/src/senaite/core/tests/doctests/AnalysisProfile.rst b/src/senaite/core/tests/doctests/AnalysisProfile.rst index c84bdf6ab9..7aaa5dcc4b 100644 --- a/src/senaite/core/tests/doctests/AnalysisProfile.rst +++ b/src/senaite/core/tests/doctests/AnalysisProfile.rst @@ -196,3 +196,59 @@ Setting the profile works up to this state: >>> services = get_services(ar3) >>> set(map(api.get_uid, services)).issuperset(service_uids1 + [Au.UID()]) True + + +Inactive services +................. + +Inactive services are not returned by default: + + >>> Fe in profile1.getServices() + True + + >>> success = do_action_for(Fe, "deactivate") + >>> api.get_workflow_status_of(Fe) + 'inactive' + + >>> Fe in profile1.getServices() + False + +But kept as raw data, just in case we re-activate the service later: + + >>> Fe in profile1.getServices(active_only=False) + True + + >>> api.get_uid(Fe) in profile1.getRawServiceUIDs() + True + +By default, inactive services are kept as raw data when the value is set: + + >>> profile1.setServices([]) + >>> Cu in profile1.getServices() + False + >>> Fe in profile1.getServices() + False + >>> api.get_uid(Cu) in profile1.getServiceUIDs() + False + >>> api.get_uid(Fe) in profile1.getServiceUIDs() + False + >>> api.get_uid(Cu) in profile1.getRawServiceUIDs() + False + >>> api.get_uid(Fe) in profile1.getRawServiceUIDs() + True + +Unless we use `keep_inactive=False`: + + >>> profile1.setServices([], keep_inactive=False) + >>> Cu in profile1.getServices() + False + >>> Fe in profile1.getServices() + False + >>> api.get_uid(Cu) in profile1.getServiceUIDs() + False + >>> api.get_uid(Fe) in profile1.getServiceUIDs() + False + >>> api.get_uid(Cu) in profile1.getRawServiceUIDs() + False + >>> api.get_uid(Fe) in profile1.getRawServiceUIDs() + False diff --git a/src/senaite/core/tests/doctests/AnalysisServiceInactivation.rst b/src/senaite/core/tests/doctests/AnalysisServiceInactivation.rst index a9482db352..5287a6fa60 100644 --- a/src/senaite/core/tests/doctests/AnalysisServiceInactivation.rst +++ b/src/senaite/core/tests/doctests/AnalysisServiceInactivation.rst @@ -142,8 +142,8 @@ But if we activate `Ca` again: True -Deactivated service is removed automatically from profiles -.......................................................... +Deactivated service is kept in profiles, but not returned +......................................................... Create a profile: @@ -162,14 +162,77 @@ If we deactivate `Au`: Profile does no longer contain this service: - >>> [service.getKeyword() for service in profile.getServices()] + >>> services = profile.getServices() + >>> [service.getKeyword() for service in services] ['Ca', 'Mg'] +Unless we explicitly ask to include inactive ones: + + >>> services = profile.getServices(active_only=False) + >>> [service.getKeyword() for service in services] + ['Ca', 'Mg', 'Au'] + +So the reference to inactive services is kept as a raw value: + >>> len(profile.getRawServices()) - 2 + 3 -Re-activate `Au`: +And if we re-activate `Au`: >>> performed = doActionFor(Au, 'activate') >>> api.is_active(Au) True + +The profile returns the service again: + + >>> services = profile.getServices() + >>> [service.getKeyword() for service in services] + ['Ca', 'Mg', 'Au'] + + +Deactivated service is kept in templates, but not returned +.......................................................... + +Create a sample template: + + >>> template = api.create(setup.sampletemplates, "SampleTemplate") + >>> template.setServices([Ca, Mg, Au]) + >>> [service.getKeyword() for service in template.getServices()] + ['Ca', 'Mg', 'Au'] + >>> len(template.getRawServices()) + 3 + +If we deactivate `Au`: + + >>> performed = doActionFor(Au, 'deactivate') + >>> api.is_active(Au) + False + +Template does no longer contain this service: + + >>> services = template.getServices() + >>> [service.getKeyword() for service in services] + ['Ca', 'Mg'] + +Unless we explicitly ask to include inactive ones: + + >>> services = template.getServices(active_only=False) + >>> [service.getKeyword() for service in services] + ['Ca', 'Mg', 'Au'] + +So the reference to inactive services is kept as a raw value: + + >>> len(profile.getRawServices()) + 3 + +And if we re-activate `Au`: + + >>> performed = doActionFor(Au, 'activate') + >>> api.is_active(Au) + True + +The template returns the service again: + + >>> services = template.getServices() + >>> [service.getKeyword() for service in services] + ['Ca', 'Mg', 'Au'] diff --git a/src/senaite/core/tests/doctests/SampleTemplate.rst b/src/senaite/core/tests/doctests/SampleTemplate.rst index 7e3f9311ba..995bb8e306 100644 --- a/src/senaite/core/tests/doctests/SampleTemplate.rst +++ b/src/senaite/core/tests/doctests/SampleTemplate.rst @@ -230,7 +230,7 @@ Test get/set methods: Services ^^^^^^^^ -Anbalysis Services can be assigned to the Template, so that they are +Analysis Services can be assigned to the Template, so that they are automatically added when the sample is created. Each service can be configured for a specific partition and if it should be @@ -305,7 +305,7 @@ Get the partition ID for a given service: Get the service UIDs for all assigned services: >>> uids = [api.get_uid(Fe), api.get_uid(Cu), api.get_uid(Au)] - >>> all(map(lambda uid: uid in uids, template1.getAnalysisServiceUIDs())) + >>> all(map(lambda uid: uid in uids, template1.getServiceUIDs())) True Update the settings for *all* assigned services with `setAnalysisServicesSettings` (plural): @@ -320,7 +320,7 @@ Unassign a service from the template: >>> template1.remove_service(Au) True - >>> api.get_uid(Au) in template1.getAnalysisServiceUIDs() + >>> api.get_uid(Au) in template1.getServiceUIDs() False >>> template1.remove_service(Au) @@ -328,16 +328,64 @@ Unassign a service from the template: Unassignment happens automatically if an Analysis Service was deactivated: - >>> api.get_uid(Fe) in template1.getAnalysisServiceUIDs() + >>> Fe in template1.getServices() + True + + >>> api.get_uid(Fe) in template1.getServiceUIDs() + True + + >>> api.get_uid(Fe) in template1.getRawServiceUIDs() True >>> api.get_workflow_status_of(Fe) 'active' >>> success = do_action_for(Fe, "deactivate") - >>> api.get_workflow_status_of(Fe) 'inactive' - >>> api.get_uid(Fe) in template1.getAnalysisServiceUIDs() + >>> Fe in template1.getServices() + False + + >>> api.get_uid(Fe) in template1.getServiceUIDs() + False + +But are kept as raw data, just in case we re-activate the service later: + + >>> Fe in template1.getServices(active_only=False) + True + + >>> api.get_uid(Fe) in template1.getRawServiceUIDs() + True + +By default, inactive services are kept as raw data when the value is set: + + >>> template1.setServices([]) + >>> Cu in template1.getServices() + False + >>> Fe in template1.getServices() + False + >>> api.get_uid(Cu) in template1.getServiceUIDs() + False + >>> api.get_uid(Fe) in template1.getServiceUIDs() + False + >>> api.get_uid(Cu) in template1.getRawServiceUIDs() + False + >>> api.get_uid(Fe) in template1.getRawServiceUIDs() + True + +Unless we use `keep_inactive=False`: + + >>> template1.setServices([], keep_inactive=False) + >>> Cu in template1.getServices() + False + >>> Fe in template1.getServices() + False + >>> api.get_uid(Cu) in template1.getServiceUIDs() + False + >>> api.get_uid(Fe) in template1.getServiceUIDs() + False + >>> api.get_uid(Cu) in template1.getRawServiceUIDs() + False + >>> api.get_uid(Fe) in template1.getRawServiceUIDs() False From 7be1e36633cb3d6410e0c3c612e6d6149591208b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Tue, 19 Nov 2024 21:09:37 +0100 Subject: [PATCH 2/2] Added "Maximum holding time" setting to services and analyses (#2624) * Added analytical holding time functionality * Typo * Redux * Trigger recalculate_records on sample date change * do not display the lock button if beyond holding time * Remove unnecessary bits Surprisingly, this snippet is not necessary because when a sample is created via add form, the uids of services (those assigned to profiles included) are passed on submit * Add warning icon when analysis is beyond holding time * Remove unnecessary bits * Clean import * Changelog * Improve texts * Better wording * CHangelog * better spelling * Fix TypeError: can't compare datetime.datetime to str * Added timezone parameter to `to_ansi` dtime function * Add now() function in api.dtime * Fix doctest * Fix inconsistency with Etc/GMT timezone when datetime is used * Rely on to_ansi with timezone for comparisons * Fix UnknownTimeZoneError when extracting the timezone from datetime ``` Traceback (innermost last): Module ZPublisher.WSGIPublisher, line 176, in transaction_pubevents Module ZPublisher.WSGIPublisher, line 385, in publish_module Module ZPublisher.WSGIPublisher, line 288, in publish Module ZPublisher.mapply, line 85, in mapply Module ZPublisher.WSGIPublisher, line 63, in call_object Module senaite.app.listing.view, line 246, in __call__ Module senaite.app.listing.ajax, line 113, in handle_subpath Module senaite.core.decorators, line 40, in decorator Module senaite.app.listing.decorators, line 63, in wrapper Module senaite.app.listing.decorators, line 50, in wrapper Module senaite.app.listing.decorators, line 100, in wrapper Module senaite.app.listing.ajax, line 383, in ajax_folderitems Module senaite.app.listing.decorators, line 88, in wrapper Module senaite.app.listing.ajax, line 259, in get_folderitems Module bika.lims.browser.analyses.view, line 808, in folderitems Module senaite.app.listing.view, line 982, in folderitems Module bika.lims.browser.analyses.view, line 797, in folderitem Module bika.lims.browser.analyses.view, line 1737, in _folder_item_holding_time Module senaite.core.api.dtime, line 262, in to_ansi Module senaite.core.api.dtime, line 382, in to_zone Module pytz, line 188, in timezone UnknownTimeZoneError: '+02' ``` * Do not raise error if timezone is not valid in to_ansi * Better wording * Proper comparison of dates in sample add form --------- Co-authored-by: Ramon Bartl --- CHANGES.rst | 1 + src/bika/lims/browser/analyses/view.py | 69 ++++++++++++++ src/bika/lims/browser/analysisrequest/add2.py | 68 +++++++++++++ .../analysisrequest/templates/ar_add2.pt | 10 ++ src/bika/lims/content/abstractbaseanalysis.py | 40 ++++++++ src/bika/lims/utils/__init__.py | 10 ++ src/senaite/core/api/dtime.py | 70 ++++++++++++-- .../js/bika.lims.analysisrequest.add.js | 32 ++++++- .../bika.lims.analysisrequest.add.coffee | 27 +++++- .../core/tests/doctests/API_datetime.rst | 95 +++++++++++++++++++ 10 files changed, 407 insertions(+), 15 deletions(-) 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 ................................