diff --git a/CHANGES.rst b/CHANGES.rst
index 52125afdc7..e560739b31 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,7 @@ Changelog
2.6.0 (unreleased)
------------------
+- #2120 Support for Multiple component analysis
- #2514 Added functions for easy update of workflows
- #2512 Skip rendering of empty record fiels
- #2510 Fix Add action of the Client located Analysis Profiles Listing
diff --git a/src/bika/lims/browser/analyses/view.py b/src/bika/lims/browser/analyses/view.py
index 6fd0a6d882..a5549703c2 100644
--- a/src/bika/lims/browser/analyses/view.py
+++ b/src/bika/lims/browser/analyses/view.py
@@ -81,6 +81,7 @@ def __init__(self, context, request, **kwargs):
"portal_type": "Analysis",
"sort_on": "sortable_title",
"sort_order": "ascending",
+ "isAnalyte": False,
})
# set the listing view config
@@ -387,6 +388,10 @@ def is_result_edition_allowed(self, analysis_brain):
# Get the ananylsis object
obj = self.get_object(analysis_brain)
+ if obj.isMultiComponent():
+ # The results entry cannot be done directly, but for its analytes
+ return False
+
if not obj.getDetectionLimitOperand():
# This is a regular result (not a detection limit)
return True
@@ -778,6 +783,11 @@ def folderitem(self, obj, item, index):
# Renders the analysis conditions
self._folder_item_conditions(obj, item)
+ # Analytes (if a multi component analysis)
+ obj = self.get_object(obj)
+ item["children"] = obj.getRawAnalytes()
+ item["parent"] = obj.getRawMultiComponentAnalysis()
+
return item
def folderitems(self):
@@ -940,6 +950,12 @@ def _folder_item_result(self, analysis_brain, item):
item["before"]["Result"] = img
return
+ # Get the analysis object
+ obj = self.get_object(analysis_brain)
+ if obj.isMultiComponent():
+ # Don't display the "NA" result of a multi-component analysis
+ return
+
result = analysis_brain.getResult
capture_date = analysis_brain.getResultCaptureDate
capture_date_str = self.ulocalized_time(capture_date, long_format=0)
@@ -953,9 +969,6 @@ def _folder_item_result(self, analysis_brain, item):
if unit:
item["after"]["Result"] = self.render_unit(unit)
- # Get the analysis object
- obj = self.get_object(analysis_brain)
-
# Edit mode enabled of this Analysis
if self.is_analysis_edition_allowed(analysis_brain):
# Allow to set Remarks
@@ -1167,6 +1180,12 @@ def _folder_item_unit(self, analysis_brain, item):
if not self.is_analysis_edition_allowed(analysis_brain):
return
+ obj = self.get_object(analysis_brain)
+ if obj.isMultiComponent():
+ # Leave units empty
+ item["Unit"] = ""
+ return
+
# Edition allowed
voc = self.get_unit_vocabulary(analysis_brain)
if voc:
@@ -1179,7 +1198,11 @@ def _folder_item_method(self, analysis_brain, item):
:param analysis_brain: Brain that represents an analysis
:param item: analysis' dictionary counterpart that represents a row
"""
+ item["Method"] = ""
obj = self.get_object(analysis_brain)
+ if obj.isAnalyte():
+ return
+
is_editable = self.is_analysis_edition_allowed(analysis_brain)
if is_editable:
method_vocabulary = self.get_methods_vocabulary(analysis_brain)
@@ -1221,6 +1244,9 @@ def _folder_item_instrument(self, analysis_brain, item):
:param item: analysis' dictionary counterpart that represents a row
"""
item["Instrument"] = ""
+ obj = self.get_object(analysis_brain)
+ if obj.isAnalyte():
+ return
# Instrument can be assigned to this analysis
is_editable = self.is_analysis_edition_allowed(analysis_brain)
@@ -1261,7 +1287,13 @@ def _folder_item_analyst(self, obj, item):
item["Analyst"] = self.get_user_name(analyst)
def _folder_item_submitted_by(self, obj, item):
+ item["SubmittedBy"] = ""
+
obj = self.get_object(obj)
+ if obj.isMultiComponent():
+ # Do not display submitter for multi-component analyses
+ return
+
submitted_by = obj.getSubmittedBy()
item["SubmittedBy"] = self.get_user_name(submitted_by)
@@ -1357,10 +1389,16 @@ def _folder_item_detection_limits(self, analysis_brain, item):
if not obj.getDetectionLimitSelector():
return None
+ # Display the column for the selector
+ self.columns["DetectionLimitOperand"]["toggle"] = True
+
+ # If multicomponent only render the selector for analytes
+ if obj.isMultiComponent():
+ return
+
# Show Detection Limit Operand Selector
item["DetectionLimitOperand"] = obj.getDetectionLimitOperand()
item["allow_edit"].append("DetectionLimitOperand")
- self.columns["DetectionLimitOperand"]["toggle"] = True
# Prepare selection list for LDL/UDL
choices = [
@@ -1583,6 +1621,9 @@ def _folder_item_report_visibility(self, analysis_brain, item):
return
full_obj = self.get_object(analysis_brain)
+ if full_obj.isMultiComponent():
+ return
+
item['Hidden'] = full_obj.getHidden()
# Hidden checkbox is not reachable by tabbing
diff --git a/src/bika/lims/browser/analysisrequest/add2.py b/src/bika/lims/browser/analysisrequest/add2.py
index 5e625b9f9b..0cd125a4c9 100644
--- a/src/bika/lims/browser/analysisrequest/add2.py
+++ b/src/bika/lims/browser/analysisrequest/add2.py
@@ -927,6 +927,7 @@ def get_service_info(self, obj):
"category": obj.getCategoryTitle(),
"poc": obj.getPointOfCapture(),
"conditions": self.get_conditions_info(obj),
+ "analytes": obj.getAnalytes(),
})
dependencies = get_calculation_dependencies_for(obj).values()
diff --git a/src/bika/lims/browser/analysisrequest/templates/ar_add2.pt b/src/bika/lims/browser/analysisrequest/templates/ar_add2.pt
index bedd7b1d12..ef6b37e34c 100644
--- a/src/bika/lims/browser/analysisrequest/templates/ar_add2.pt
+++ b/src/bika/lims/browser/analysisrequest/templates/ar_add2.pt
@@ -292,6 +292,44 @@
+
+
@@ -571,6 +609,11 @@
class python:'{}-conditions service-conditions'.format(service_uid);">
+
+
+
+
diff --git a/src/bika/lims/browser/fields/aranalysesfield.py b/src/bika/lims/browser/fields/aranalysesfield.py
index 2747b6e304..f0afecad39 100644
--- a/src/bika/lims/browser/fields/aranalysesfield.py
+++ b/src/bika/lims/browser/fields/aranalysesfield.py
@@ -31,6 +31,7 @@
from bika.lims.interfaces import ISubmitted
from senaite.core.permissions import AddAnalysis
from bika.lims.utils.analysis import create_analysis
+from bika.lims.utils.analysis import create_analytes
from Products.Archetypes.public import Field
from Products.Archetypes.public import ObjectField
from Products.Archetypes.Registry import registerField
@@ -277,6 +278,10 @@ def add_analysis(self, instance, service, **kwargs):
analysis = create_analysis(instance, service)
analyses.append(analysis)
+ # Create the analytes if multi-component analysis
+ analytes = create_analytes(analysis)
+ analyses.extend(analytes)
+
for analysis in analyses:
# Set the hidden status
analysis.setHidden(hidden)
@@ -288,18 +293,17 @@ def add_analysis(self, instance, service, **kwargs):
parent_sample = analysis.getRequest()
analysis.setInternalUse(parent_sample.getInternalUse())
- # Set the default result to the analysis
- if not analysis.getResult() and default_result:
- analysis.setResult(default_result)
- analysis.setResultCaptureDate(None)
+ # Set default result, but only if not a multi-component
+ self.set_default_result(analysis, default_result)
# Set the result range to the analysis
analysis_rr = specs.get(service_uid) or analysis.getResultsRange()
analysis.setResultsRange(analysis_rr)
# Set default (pre)conditions
- conditions = self.resolve_conditions(analysis)
- analysis.setConditions(conditions)
+ if not analysis.isAnalyte():
+ conditions = self.resolve_conditions(analysis)
+ analysis.setConditions(conditions)
analysis.reindexObject()
@@ -368,6 +372,31 @@ def resolve_analyses(self, instance, service):
return analyses
+ def set_default_result(self, analysis, default_result):
+ """Sets the default result to the analysis w/o updating the results
+ capture date. It does nothing if the instance is a multi-component
+ analysis or if the analysis has a result already set
+ """
+ if not default_result:
+ return
+ if analysis.getResult():
+ return
+ if analysis.isMultiComponent():
+ return
+
+ # keep track of original capture date of the multi-component the
+ # analysis belongs to
+ multi = analysis.getMultiComponentAnalysis()
+ multi_capture = multi.getResultCaptureDate() if multi else None
+
+ # set the default result and reset capture date
+ analysis.setResult(default_result)
+ analysis.setResultCaptureDate(None)
+
+ # if multi, restore the original capture date
+ if multi:
+ multi.setResultCaptureDate(multi_capture)
+
def get_analyses_from_descendants(self, instance):
"""Returns all the analyses from descendants
"""
diff --git a/src/bika/lims/browser/worksheet/views/add_analyses.py b/src/bika/lims/browser/worksheet/views/add_analyses.py
index 4639882f2c..f55cd21391 100644
--- a/src/bika/lims/browser/worksheet/views/add_analyses.py
+++ b/src/bika/lims/browser/worksheet/views/add_analyses.py
@@ -66,6 +66,7 @@ def __init__(self, context, request):
self.contentFilter = {
"portal_type": "Analysis",
"review_state": "unassigned",
+ "isAnalyte": False,
"isSampleReceived": True,
"sort_on": "getPrioritySortkey",
}
diff --git a/src/bika/lims/browser/worksheet/views/analyses.py b/src/bika/lims/browser/worksheet/views/analyses.py
index 4812591772..7f326c71ee 100644
--- a/src/bika/lims/browser/worksheet/views/analyses.py
+++ b/src/bika/lims/browser/worksheet/views/analyses.py
@@ -240,6 +240,10 @@ def folderitem(self, obj, item, index):
item_obj = api.get_object(obj)
uid = item["uid"]
+ # Analytes are rendered like the rest, as a flat list
+ item["parent"] = ""
+ item["children"] = ""
+
# Slot is the row position where all analyses sharing the same parent
# (eg. AnalysisRequest, SampleReference), will be displayed as a group
slot = self.get_item_slot(uid)
diff --git a/src/bika/lims/content/abstractanalysis.py b/src/bika/lims/content/abstractanalysis.py
index 9111e2d609..4f694128b1 100644
--- a/src/bika/lims/content/abstractanalysis.py
+++ b/src/bika/lims/content/abstractanalysis.py
@@ -159,6 +159,12 @@
required=0
)
+# The Multi-component Analysis this analysis belongs to
+MultiComponentAnalysis = UIDReferenceField(
+ "MultiComponentAnalysis",
+ relationship="AnalysisMultiComponentAnalysis",
+)
+
schema = schema.copy() + Schema((
AnalysisService,
Analyst,
@@ -173,6 +179,7 @@
Calculation,
InterimFields,
ResultsRange,
+ MultiComponentAnalysis,
))
@@ -459,6 +466,11 @@ def setResult(self, value):
account the Detection Limits.
:param value: is expected to be a string.
"""
+ if self.isMultiComponent():
+ # Cannot set a result to a multi-component analysis
+ msg = "setResult is not supported for Multi-component analyses"
+ raise ValueError(msg)
+
prev_result = self.getField("Result").get(self) or ""
# Convert to list ff the analysis has result options set with multi
@@ -515,6 +527,16 @@ def setResult(self, value):
# Set the result field
self.getField("Result").set(self, val)
+ # Set a 'NA' result to the multi-component analysis this belongs to,
+ # but only if results for all analytes have been captured
+ if val and prev_result != val:
+ multi_component = self.getMultiComponentAnalysis()
+ if multi_component:
+ captured = self.getResultCaptureDate()
+ multi_component.setStringResult(True)
+ multi_component.getField("Result").set(multi_component, "NA")
+ multi_component.setResultCaptureDate(captured)
+
@security.public
def calculateResult(self, override=False, cascade=False):
"""Calculates the result for the current analysis if it depends of
@@ -1026,7 +1048,7 @@ def getAnalyst(self):
"""Returns the stored Analyst or the user who submitted the result
"""
analyst = self.getField("Analyst").get(self) or self.getAssignedAnalyst()
- if not analyst:
+ if not analyst and not self.isMultiComponent():
analyst = self.getSubmittedBy()
return analyst or ""
@@ -1189,3 +1211,55 @@ def isRetested(self):
if self.getRawRetest():
return True
return False
+
+ def getRawAnalytes(self):
+ """Returns the UIDs of the analytes of this multi-component analysis
+ """
+ return get_backreferences(self, "AnalysisMultiComponentAnalysis")
+
+ def getAnalytes(self):
+ """Returns the analytes of this multi-component analysis, if any
+ """
+ uids = self.getRawAnalytes()
+ if not uids:
+ return []
+
+ cat = api.get_tool("uid_catalog")
+ brains = cat(UID=uids)
+ return [api.get_object(brain) for brain in brains]
+
+ def isAnalyte(self):
+ """Returns whether this analysis is an analyte of a multi-component
+ analysis
+ """
+ if self.getRawMultiComponentAnalysis():
+ return True
+ return False
+
+ def isMultiComponent(self):
+ """Returns whether this analysis is a multi-component analysis
+ """
+ if self.getRawAnalytes():
+ return True
+ return False
+
+ def setMethod(self, value):
+ """Sets the method to this analysis and analytes if multi-component
+ """
+ self.getField("Method").set(self, value)
+ for analyte in self.getAnalytes():
+ analyte.setMethod(value)
+
+ def setInstrument(self, value):
+ """Sets the method to this analysis and analytes if multi-component
+ """
+ self.getField("Instrument").set(self, value)
+ for analyte in self.getAnalytes():
+ analyte.setInstrument(value)
+
+ def setAnalyst(self, value):
+ """Sets the analyst to this analysis and analytes if multi-component
+ """
+ self.getField("Analyst").set(self, value)
+ for analyte in self.getAnalytes():
+ analyte.setAnalyst(value)
diff --git a/src/bika/lims/content/abstractroutineanalysis.py b/src/bika/lims/content/abstractroutineanalysis.py
index a6d32e8989..fe314253cb 100644
--- a/src/bika/lims/content/abstractroutineanalysis.py
+++ b/src/bika/lims/content/abstractroutineanalysis.py
@@ -483,6 +483,9 @@ def getConditions(self, empties=False):
set on sample registration and are stored at sample level. Do not return
conditions with empty value unless `empties` is True
"""
+ if self.isAnalyte():
+ return []
+
sample = self.getRequest()
service_uid = self.getRawAnalysisService()
attachments = self.getRawAttachment()
@@ -509,6 +512,9 @@ def setConditions(self, conditions):
if not conditions:
conditions = []
+ if conditions and self.isAnalyte():
+ raise ValueError("Conditions for analytes is not supported")
+
sample = self.getRequest()
service_uid = self.getRawAnalysisService()
sample_conditions = sample.getServiceConditions()
diff --git a/src/bika/lims/content/analysisrequest.py b/src/bika/lims/content/analysisrequest.py
index a08949cea0..a004872d86 100644
--- a/src/bika/lims/content/analysisrequest.py
+++ b/src/bika/lims/content/analysisrequest.py
@@ -19,6 +19,7 @@
# Some rights reserved, see README and LICENSE.
import base64
+import copy
import functools
import re
from datetime import datetime
@@ -1435,6 +1436,13 @@
render_own_label=True,
),
),
+
+ # Selected analytes from multi-component analyses on Sample registration
+ RecordsField(
+ "ServiceAnalytes",
+ widget=ComputedWidget(visible=False)
+ ),
+
))
@@ -2624,5 +2632,22 @@ def get_profiles_query(self):
}
return query
+ def getServiceAnalytesFor(self, service_or_uid):
+ """Return a list of dicts representing the analytes selected for this
+ sample and the given service. These analytes are usually selected on
+ sample registration
+ """
+ analytes = []
+ true_values = ("true", "1", "on", "True", True, 1)
+ target_uid = api.get_uid(service_or_uid)
+ all_analytes = self.getServiceAnalytes() or []
+ all_analytes = copy.deepcopy(all_analytes)
+ for analyte in all_analytes:
+ if analyte.get("uid") != target_uid:
+ continue
+ if analyte.get("value", None) in true_values:
+ analytes.append(analyte)
+ return analytes
+
registerType(AnalysisRequest, PROJECTNAME)
diff --git a/src/bika/lims/content/analysisservice.py b/src/bika/lims/content/analysisservice.py
index 97c23db74e..f0d02519dd 100644
--- a/src/bika/lims/content/analysisservice.py
+++ b/src/bika/lims/content/analysisservice.py
@@ -273,6 +273,80 @@
)
)
+Analytes = RecordsField(
+ "Analytes",
+ schemata="Analytes",
+ subfields=(
+ "title",
+ "keyword",
+ "selectdl",
+ "manualdl",
+ "selected",
+ ),
+ required_subfields=(
+ "title",
+ "keyword",
+ ),
+ subfield_labels={
+ "title": _(
+ u"label_analysisservice_analytes_title",
+ default=u"Name"
+ ),
+ "keyword": _(
+ u"label_analysisservice_analytes_keyword",
+ default=u"Keyword"
+ ),
+ "selectdl": _(
+ u"label_analysisservice_analytes_dlselector",
+ default=u"DL Selector"
+ ),
+ "manualdl": _(
+ u"label_analysisservice_analytes_manualdl",
+ default=u"Manual DL"
+ ),
+ "selected": _(
+ u"label_analysisservice_analytes_selected",
+ default=u"Selected"
+ ),
+ },
+ subfield_descriptions={
+ "selected": _(
+ u"description_analysisservice_analytes_selected",
+ default=u"Whether this analyte is checked by default when "
+ u"the multi-component analysis is selected in the sample "
+ u"registration form. Only checked analytes will be "
+ u"available for results entry after sample creation"
+ ),
+ },
+ subfield_types={
+ "title": "string",
+ "keyword": "string",
+ "selectdl": "boolean",
+ "manualdl": "boolean",
+ "selected": "boolean",
+ },
+ subfield_sizes={
+ "title": 20,
+ "keyword": 20,
+ "selectdl": 20,
+ "manualdl": 20,
+ "selected": 20,
+ },
+ subfield_validators={
+ "keyword": "service_analytes_validator",
+ },
+ subfield_maxlength={
+ "title": 50,
+ "keyword": 20,
+ },
+ widget=RecordsWidget(
+ label=_(u"label_analysisservice_analytes", default="Analytes"),
+ description=_(
+ u"description_analysisservice_analytes",
+ default=u"Individual components of this multi-component analysis"
+ ),
+ )
+)
schema = schema.copy() + Schema((
Methods,
@@ -286,6 +360,7 @@
PartitionSetup,
DefaultResult,
Conditions,
+ Analytes,
))
# Move default method field after available methods field
@@ -616,5 +691,11 @@ def getAvailableMethodUIDs(self):
# N.B. we return a copy of the list to avoid accidental writes
return self.getRawMethods()[:]
+ def isMultiComponent(self):
+ """Returns whether this service is a multi-component service
+ """
+ analytes = self.getAnalytes() or []
+ return len(analytes) > 0
+
registerType(AnalysisService, PROJECTNAME)
diff --git a/src/bika/lims/content/worksheet.py b/src/bika/lims/content/worksheet.py
index 816695ee3d..1b94854b3a 100644
--- a/src/bika/lims/content/worksheet.py
+++ b/src/bika/lims/content/worksheet.py
@@ -188,7 +188,7 @@ def addAnalysis(self, analysis, position=None):
return
# Cannot add an analysis that is assigned already
- if analysis.getWorksheet():
+ if analysis.getWorksheetUID():
return
# Just in case
@@ -230,6 +230,11 @@ def addAnalysis(self, analysis, position=None):
self.setAnalyses(analyses + [analysis])
self.addToLayout(analysis, position)
+ # Add the analytes if multi-component analysis
+ analytes = analysis.getAnalytes()
+ if analytes:
+ self.addAnalyses(analytes)
+
# Try to rollback the worksheet to prevent inconsistencies
doActionFor(self, "rollback_to_open")
diff --git a/src/bika/lims/controlpanel/bika_analysisservices.py b/src/bika/lims/controlpanel/bika_analysisservices.py
index 1e347d35a2..1212cb78b2 100644
--- a/src/bika/lims/controlpanel/bika_analysisservices.py
+++ b/src/bika/lims/controlpanel/bika_analysisservices.py
@@ -379,6 +379,10 @@ def folderitem(self, obj, item, index):
if after_icons:
item["after"]["Title"] = after_icons
+ if obj.isMultiComponent():
+ img = get_image("multicomponent.svg", title=_("Multiple component"))
+ item["before"]["Title"] = img
+
return item
def folderitems(self):
diff --git a/src/bika/lims/utils/__init__.py b/src/bika/lims/utils/__init__.py
index 47a758937a..68f766de36 100644
--- a/src/bika/lims/utils/__init__.py
+++ b/src/bika/lims/utils/__init__.py
@@ -926,3 +926,10 @@ def get_client(obj):
return obj.getClient()
return None
+
+
+def is_true(val):
+ """Returns whether val evaluates to True
+ """
+ val = str(val).strip().lower()
+ return val in ["y", "yes", "1", "true", "on"]
diff --git a/src/bika/lims/utils/analysis.py b/src/bika/lims/utils/analysis.py
index 1e58e1f072..50c1839914 100644
--- a/src/bika/lims/utils/analysis.py
+++ b/src/bika/lims/utils/analysis.py
@@ -28,6 +28,7 @@
from bika.lims.interfaces.analysis import IRequestAnalysis
from bika.lims.utils import formatDecimalMark
from bika.lims.utils import format_supsub
+from bika.lims.utils import is_true
def create_analysis(context, source, **kwargs):
@@ -364,11 +365,16 @@ def create_retest(analysis, **kwargs):
})
retest = create_analysis(parent, analysis, **kwargs)
+ # Create the analytes if necessary
+ if analysis.isMultiComponent():
+ create_analytes(retest)
+
# Add the retest to the same worksheet, if any
worksheet = analysis.getWorksheet()
if worksheet:
worksheet.addAnalysis(retest)
+ retest.reindexObject()
return retest
@@ -409,6 +415,64 @@ def create_reference_analysis(reference_sample, source, **kwargs):
return create_analysis(ref, source, **kwargs)
+def create_analytes(analysis):
+ """Creates Analysis objects that represent analytes of the given multi
+ component analysis. Returns empty otherwise
+ """
+ if analysis.isAnalyte():
+ raise ValueError("An analyte already: {}".format(analysis))
+
+ analytes = []
+ service = analysis.getAnalysisService()
+ container = api.get_parent(analysis)
+
+ keywords = []
+
+ # if a retest, pick the analytes from the retested
+ retests_of = {}
+ if hasattr(analysis, 'getRetestOf'):
+ retested = analysis.getRetestOf()
+ analytes = retested and retested.getAnalytes() or []
+
+ # skip those that are not valid
+ skip = ["cancelled", "retracted", "rejected"]
+ get_status = api.get_review_status
+ analytes = filter(lambda an: get_status(an) not in skip, analytes)
+
+ # extract the keywords and map with original analyte
+ keywords = [analyte.getKeyword() for analyte in analytes]
+ retests_of = dict(zip(keywords, analytes))
+
+ # pick the analytes that were selected for this service and sample
+ if not keywords and IRequestAnalysis.providedBy(analysis):
+ sample = analysis.getRequest()
+ sample_analytes = sample.getServiceAnalytesFor(service)
+ keywords = [analyte.get("keyword") for analyte in sample_analytes]
+
+ for analyte_record in service.getAnalytes():
+ keyword = analyte_record.get("keyword")
+ if keywords and keyword not in keywords:
+ continue
+
+ analyte_id = generate_analysis_id(container, keyword)
+ retest_of = retests_of.get(keyword, None)
+ select_dl = analyte_record.get("selectdl")
+ manual_dl = analyte_record.get("manualdl")
+ values = {
+ "id": analyte_id,
+ "title": analyte_record.get("title"),
+ "Keyword": keyword,
+ "MultiComponentAnalysis": analysis,
+ "RetestOf": retest_of,
+ "DetectionLimitSelector": is_true(select_dl),
+ "AllowManualDetectionLimit": is_true(manual_dl),
+ }
+ analyte = create_analysis(container, service, **values)
+ analytes.append(analyte)
+
+ return analytes
+
+
def generate_analysis_id(instance, keyword):
"""Generates a new analysis ID
"""
diff --git a/src/bika/lims/utils/analysisrequest.py b/src/bika/lims/utils/analysisrequest.py
index 34a134df50..da601e72c4 100644
--- a/src/bika/lims/utils/analysisrequest.py
+++ b/src/bika/lims/utils/analysisrequest.py
@@ -366,6 +366,9 @@ def create_retest(ar):
# Rename the retest according to the ID server setup
renameAfterCreation(retest)
+ # Keep a mapping of UIDs between original and new analyses
+ source_target = {}
+
# Copy the analyses from the source
intermediate_states = ['retracted', ]
for an in ar.getAnalyses(full_objects=True):
@@ -388,9 +391,20 @@ def create_retest(ar):
nan = _createObjectByType("Analysis", retest, keyword)
# Make a copy
- ignore_fieldnames = ['DataAnalysisPublished']
+ ignore_fieldnames = ['DataAnalysisPublished', 'MultiComponentAnalysis']
copy_field_values(an, nan, ignore_fieldnames=ignore_fieldnames)
nan.unmarkCreationFlag()
+
+ # Keep track of relationships
+ source_uid = api.get_uid(an)
+ source_target[source_uid] = api.get_uid(nan)
+
+ # If analyte, assign the proper parent multi-component
+ if an.isAnalyte():
+ source_multi_uid = an.getRawMultiComponentAnalysis()
+ target_multi_uid = source_target.get(source_multi_uid)
+ nan.setMultiComponentAnalysis(target_multi_uid)
+
push_reindex_to_actions_pool(nan)
# Transition the retest to "sample_received"!
diff --git a/src/bika/lims/validators.py b/src/bika/lims/validators.py
index 68ea25c4fa..9c85ce7bce 100644
--- a/src/bika/lims/validators.py
+++ b/src/bika/lims/validators.py
@@ -38,6 +38,9 @@
from zope.interface import implements
+RX_NO_SPECIAL_CHARACTERS = r"[^A-Za-z\w\d\-_]"
+
+
class IdentifierTypeAttributesValidator:
"""Validate IdentifierTypeAttributes to ensure that attributes are
not duplicated.
@@ -1383,11 +1386,11 @@ def __call__(self, value, **kwargs):
validation.register(DefaultResultValidator())
-class ServiceConditionsValidator(object):
- """Validate AnalysisService Conditions field
+class RecordsValidator(object):
+ """Records validator
"""
implements(IValidator)
- name = "service_conditions_validator"
+ name = "generic_records_validator"
def __call__(self, field_value, **kwargs):
instance = kwargs["instance"]
@@ -1407,14 +1410,25 @@ def __call__(self, field_value, **kwargs):
records = request.get(field_name_value, [])
for record in records:
# Validate the record
- msg = self.validate_record(record)
+ msg = self.validate_record(instance, record)
if msg:
return to_utf8(translate(msg))
instance.REQUEST[key] = True
return True
- def validate_record(self, record):
+ def validate_record(self, instance, record):
+ """Validates a dict-like item/record. Returns None if no error. Returns
+ a message otherwise"""
+ raise NotImplementedError("Must be implemented by subclass")
+
+
+class ServiceConditionsValidator(RecordsValidator):
+ """Validate AnalysisService Conditions field
+ """
+ name = "service_conditions_validator"
+
+ def validate_record(self, instance, record):
control_type = record.get("type")
choices = record.get("choices")
required = record.get("required") == "on"
@@ -1455,3 +1469,18 @@ def validate_record(self, record):
validation.register(ServiceConditionsValidator())
+
+
+class ServiceAnalytesValidator(RecordsValidator):
+ """Validate AnalysisService Analytes field
+ """
+ name = "service_analytes_validator"
+
+ def validate_record(self, instance, record):
+ # Keyword cannot contain invalid characters
+ keyword = record.get("keyword")
+ if re.findall(RX_NO_SPECIAL_CHARACTERS, keyword):
+ return _("Validation failed: Keyword contains invalid characters")
+
+
+validation.register(ServiceAnalytesValidator())
diff --git a/src/bika/lims/workflow/analysis/events.py b/src/bika/lims/workflow/analysis/events.py
index fb5ef71d10..ac0207c225 100644
--- a/src/bika/lims/workflow/analysis/events.py
+++ b/src/bika/lims/workflow/analysis/events.py
@@ -64,31 +64,32 @@ def before_reject(analysis):
def after_retest(analysis):
- """Function triggered before 'retest' transition takes place. Creates a
- copy of the current analysis
+ """Function triggered after 'retest' transition takes place. Verifies and
+ creates a copy of the given analysis, dependents and dependencies
"""
# When an analysis is retested, it automatically transitions to verified,
# so we need to mark the analysis as such
alsoProvides(analysis, IVerified)
- def verify_and_retest(relative):
- if not ISubmitted.providedBy(relative):
+ # Retest and auto-verify relatives, from bottom to top
+ to_retest = list(reversed(analysis.getDependents(recursive=True)))
+ to_retest.extend(analysis.getDependencies(recursive=True))
+ to_retest.append(analysis)
+
+ for obj in to_retest:
+ if not ISubmitted.providedBy(obj):
# Result not yet submitted, no need to create a retest
return
- # Apply the transition manually, but only if analysis can be verified
- doActionFor(relative, "verify")
-
# Create the retest
- create_retest(relative)
+ create_retest(obj)
- # Retest and auto-verify relatives, from bottom to top
- relatives = list(reversed(analysis.getDependents(recursive=True)))
- relatives.extend(analysis.getDependencies(recursive=True))
- map(verify_and_retest, relatives)
+ # Verify the analysis
+ doActionFor(obj, "verify")
- # Create the retest
- create_retest(analysis)
+ # Verify the analytes
+ for analyte in obj.getAnalytes():
+ doActionFor(analyte, "verify")
# Try to rollback the Analysis Request
if IRequestAnalysis.providedBy(analysis):
@@ -102,6 +103,11 @@ def after_unassign(analysis):
"""
# Remove from the worksheet
remove_analysis_from_worksheet(analysis)
+
+ # If multi-component, unassign all analytes as well
+ for analyte in analysis.getAnalytes():
+ doActionFor(analyte, "unassign")
+
# Reindex the Analysis Request
reindex_request(analysis)
@@ -140,6 +146,11 @@ def after_submit(analysis):
# Promote to analyses this analysis depends on
promote_to_dependencies(analysis, "submit")
+ # Promote to the multi-component analysis this analysis belongs to
+ multi_result = analysis.getMultiComponentAnalysis()
+ if multi_result:
+ doActionFor(multi_result, "submit")
+
# Promote transition to worksheet
ws = analysis.getWorksheet()
if ws:
@@ -172,8 +183,14 @@ def after_retract(analysis):
# Retract our dependencies (analyses this analysis depends on)
promote_to_dependencies(analysis, "retract")
- # Create the retest
- create_retest(analysis)
+ # If multi-component, retract all analytes as well
+ for analyte in analysis.getAnalytes():
+ doActionFor(analyte, "retract")
+
+ # Create the retest if not an Analyte, cause otherwise we end-up with a
+ # retracted multi-component analysis with non-retracted analytes inside
+ if not analysis.isAnalyte():
+ create_retest(analysis)
# Try to rollback the Analysis Request
if IRequestAnalysis.providedBy(analysis):
@@ -197,6 +214,18 @@ def after_reject(analysis):
# Reject our dependents (analyses that depend on this analysis)
cascade_to_dependents(analysis, "reject")
+ # If multi-component, reject all analytes
+ for analyte in analysis.getAnalytes():
+ doActionFor(analyte, "reject")
+
+ # If analyte, reject the multi-component if all analytes are rejected
+ multi_component = analysis.getMultiComponentAnalysis()
+ if multi_component:
+ analytes = multi_component.getAnalytes()
+ rejected = [IRejected.providedBy(analyte) for analyte in analytes]
+ if all(rejected):
+ doActionFor(multi_component, "reject")
+
if IRequestAnalysis.providedBy(analysis):
# Try verify (for when remaining analyses are in 'verified')
doActionFor(analysis.getRequest(), "verify")
@@ -223,6 +252,10 @@ def after_verify(analysis):
# Promote to analyses this analysis depends on
promote_to_dependencies(analysis, "verify")
+ # If multi-component, verify all analytes as well
+ for analyte in analysis.getAnalytes():
+ doActionFor(analyte, "verify")
+
# Promote transition to worksheet
ws = analysis.getWorksheet()
if ws:
diff --git a/src/bika/lims/workflow/analysis/guards.py b/src/bika/lims/workflow/analysis/guards.py
index e20d0b2a65..dc78573f6a 100644
--- a/src/bika/lims/workflow/analysis/guards.py
+++ b/src/bika/lims/workflow/analysis/guards.py
@@ -18,15 +18,26 @@
# Copyright 2018-2024 by it's authors.
# Some rights reserved, see README and LICENSE.
+from functools import wraps
+
from bika.lims import api
from bika.lims import logger
from bika.lims import workflow as wf
from bika.lims.api import security
+from bika.lims.interfaces import IRejectAnalysis
+from bika.lims.interfaces import IRejected
+from bika.lims.interfaces import IRetracted
from bika.lims.interfaces import ISubmitted
from bika.lims.interfaces import IVerified
from bika.lims.interfaces import IWorksheet
from bika.lims.interfaces.analysis import IRequestAnalysis
from plone.memoize.request import cache
+from zope.annotation import IAnnotations
+
+
+def get_request():
+ # Fixture for tests that do not have a regular request!!!
+ return api.get_request() or api.get_test_request()
def is_worksheet_context():
@@ -47,6 +58,36 @@ def is_worksheet_context():
return False
+def is_on_guard(analysis, guard):
+ """Function that checks if the guard for the given analysis is being
+ evaluated within the current thread. This is useful to prevent max depth
+ recursion errors when evaluating guards from interdependent objects
+ """
+ key = "guard_%s:%s" % (guard, analysis.UID())
+ storage = IAnnotations(get_request())
+ return key in storage
+
+
+def on_guard(func):
+ """Decorator that keeps track of the guard and analysis that is being
+ evaluated within the current thread. This is useful to prevent max depth
+ recursion errors when evaluating guards from independent objects
+ """
+ @wraps(func)
+ def decorator(*args):
+ analysis = args[0]
+ key = "%s:%s" % (func.__name__, analysis.UID())
+ storage = IAnnotations(get_request())
+ storage[key] = api.to_int(storage.get(key), 0) + 1
+ logger.info("{}: {}".format(key, storage[key]))
+ out = func(*args)
+ storage[key] = api.to_int(storage.get(key), 1) - 1
+ if storage[key] < 1:
+ del(storage[key])
+ return out
+ return decorator
+
+
def guard_initialize(analysis):
"""Return whether the transition "initialize" can be performed or not
"""
@@ -59,6 +100,12 @@ def guard_initialize(analysis):
def guard_assign(analysis):
"""Return whether the transition "assign" can be performed or not
"""
+ multi_component = analysis.getMultiComponentAnalysis()
+ if multi_component:
+ # Analyte can be assigned if the multi-component can be assigned or
+ # has been assigned already
+ return is_assigned_or_assignable(multi_component)
+
# Only if the request was done from worksheet context.
if not is_worksheet_context():
return False
@@ -68,21 +115,39 @@ def guard_assign(analysis):
return False
# Cannot assign if the analysis has a worksheet assigned already
- if analysis.getWorksheet():
+ if analysis.getWorksheetUID():
return False
return True
+@on_guard
def guard_unassign(analysis):
"""Return whether the transition "unassign" can be performed or not
"""
+ if analysis.isAnalyte():
+
+ # Get the multi component analysis
+ multi_component = analysis.getMultiComponentAnalysis()
+ if not multi_component.getWorksheetUID():
+ return True
+
+ # Direct un-assignment of analytes is not permitted. Return False
+ # unless the guard for the multiple component is being evaluated
+ # already in the current recursive call
+ if not is_on_guard(multi_component, "unassign"):
+ return False
+
+ # Analyte can be unassigned if the multi-component can be unassigned
+ # or has been unassigned already
+ return is_unassigned_or_unassignable(multi_component)
+
# Only if the request was done from worksheet context.
if not is_worksheet_context():
return False
# Cannot unassign if the analysis is not assigned to any worksheet
- if not analysis.getWorksheet():
+ if not analysis.getWorksheetUID():
return False
return True
@@ -146,10 +211,16 @@ def guard_submit(analysis):
if analyst != security.get_user_id():
return False
+ # If multi-component, cannot submit unless all analytes were submitted
+ for analyte in analysis.getAnalytes():
+ if not ISubmitted.providedBy(analyte):
+ return False
+
# Cannot submit unless all dependencies are submitted or can be submitted
for dependency in analysis.getDependencies():
if not is_submitted_or_submittable(dependency):
return False
+
return True
@@ -186,9 +257,36 @@ def guard_multi_verify(analysis):
return True
+@on_guard
def guard_verify(analysis):
"""Return whether the transition "verify" can be performed or not
"""
+ if analysis.isAnalyte():
+
+ # Get the multi component analysis
+ multi_component = analysis.getMultiComponentAnalysis()
+ if IVerified.providedBy(multi_component):
+ return True
+
+ # Direct verification of analytes is not permitted. Return False unless
+ # the guard for the multiple component is being evaluated already in
+ # the current recursive call
+ if not is_on_guard(multi_component, "verify"):
+ return False
+
+ elif analysis.isMultiComponent():
+
+ # Multi-component can be verified if all analytes can be verified or
+ # have already been verified
+ for analyte in analysis.getAnalytes():
+
+ # Prevent max depth exceed error
+ if is_on_guard(analyte, "verify"):
+ continue
+
+ if not is_verified_or_verifiable(analyte):
+ return False
+
# Cannot verify if the number of remaining verifications is > 1
remaining_verifications = analysis.getNumberOfRemainingVerifications()
if remaining_verifications > 1:
@@ -225,9 +323,36 @@ def guard_verify(analysis):
return True
+@on_guard
def guard_retract(analysis):
""" Return whether the transition "retract" can be performed or not
"""
+ if analysis.isAnalyte():
+
+ # Get the multi component analysis
+ multi_component = analysis.getMultiComponentAnalysis()
+ if IRetracted.providedBy(multi_component):
+ return True
+
+ # Direct retraction of analytes is not permitted. Return False unless
+ # the guard for the multiple component is being evaluated already in
+ # the current recursive call
+ if not is_on_guard(multi_component, "retract"):
+ return False
+
+ elif analysis.isMultiComponent():
+
+ # Multi-component can be retracted if all analytes can be retracted or
+ # have already been retracted
+ for analyte in analysis.getAnalytes():
+
+ # Prevent max depth exceed error
+ if is_on_guard(analyte, "retract"):
+ continue
+
+ if not is_retracted_or_retractable(analyte):
+ return False
+
# Cannot retract if there are dependents that cannot be retracted
if not is_transition_allowed(analysis.getDependents(), "retract"):
return False
@@ -243,9 +368,23 @@ def guard_retract(analysis):
return True
-def guard_retest(analysis, check_dependents=True):
+@on_guard
+def guard_retest(analysis):
"""Return whether the transition "retest" can be performed or not
"""
+ if analysis.isAnalyte():
+
+ # Get the multi component analysis
+ multi_component = analysis.getMultiComponentAnalysis()
+ if multi_component.isRetested():
+ return True
+
+ # Direct retest of analytes is not permitted. Return False unless
+ # the guard for the multiple component is being evaluated already in
+ # the current recursive call
+ if not is_on_guard(multi_component, "retest"):
+ return False
+
# Retest transition does an automatic verify transition, so the analysis
# should be verifiable first
if not is_transition_allowed(analysis, "verify"):
@@ -261,8 +400,23 @@ def guard_retest(analysis, check_dependents=True):
def guard_reject(analysis):
"""Return whether the transition "reject" can be performed or not
"""
+ if analysis.isMultiComponent():
+ # Multi-component can be rejected if all analytes can be rejected or
+ # have already been rejected
+ for analyte in analysis.getAnalytes():
+ if not is_rejected_or_rejectable(analyte):
+ return False
+
# Cannot reject if there are dependents that cannot be rejected
- return is_transition_allowed(analysis.getDependents(), "reject")
+ if not is_transition_allowed(analysis.getDependents(), "reject"):
+ return False
+
+ # Cannot reject if multi-component with analytes that cannot be rejected
+ for analyte in analysis.getAnalytes():
+ if not is_rejected_or_rejectable(analyte):
+ return False
+
+ return True
def guard_publish(analysis):
@@ -390,3 +544,46 @@ def is_verified_or_verifiable(analysis):
if is_transition_allowed(analysis, "multi_verify"):
return True
return False
+
+
+def is_rejected_or_rejectable(analysis):
+ """Returns whether the analysis is rejectable or has already been rejected
+ """
+ if IRejectAnalysis.providedBy(analysis):
+ return True
+ if IRejected.providedBy(analysis):
+ return True
+ if is_transition_allowed(analysis, "reject"):
+ return True
+ return False
+
+
+def is_retracted_or_retractable(analysis):
+ """Returns whether the analysis is retractable or has been retracted already
+ """
+ if IRetracted.providedBy(analysis):
+ return True
+ if is_transition_allowed(analysis, "retract"):
+ return True
+ return False
+
+
+def is_assigned_or_assignable(analysis):
+ """Returns whether the analysis is assignable or has been assigned already
+ """
+ if analysis.getWorksheetUID():
+ return True
+ if is_transition_allowed(analysis, "assign"):
+ return True
+ return False
+
+
+def is_unassigned_or_unassignable(analysis):
+ """Returns whether the analysis is unassignable or has been unassigned
+ already
+ """
+ if not analysis.getWorksheetUID():
+ return True
+ if is_transition_allowed(analysis, "unassign"):
+ return True
+ return False
diff --git a/src/senaite/core/browser/form/adapters/analysisservice.py b/src/senaite/core/browser/form/adapters/analysisservice.py
index 7f76933507..8f52961cf4 100644
--- a/src/senaite/core/browser/form/adapters/analysisservice.py
+++ b/src/senaite/core/browser/form/adapters/analysisservice.py
@@ -18,6 +18,7 @@
# Copyright 2018-2024 by it's authors.
# Some rights reserved, see README and LICENSE.
+from bika.lims import ServiceAnalytesValidator
from six import string_types
from itertools import chain
@@ -115,6 +116,10 @@ def modified(self, data):
self.add_update_field("Instrument", {
"options": empty + i_opts})
+ # Handle Analytes Change
+ elif name == "Analytes":
+ self.add_error_field("Analytes", self.validate_analytes(value))
+
return self.data
def get_available_instruments_for(self, methods):
@@ -185,3 +190,17 @@ def validate_keyword(self, value):
if isinstance(check, string_types):
return _(check)
return None
+
+ def validate_analytes(self, value):
+ """Validate the Analytes records
+ """
+ current_value = self.context.getAnalytes()
+ if current_value == value:
+ # nothing changed
+ return
+ # Check current value with the validator
+ validator = ServiceAnalytesValidator()
+ check = validator(value, instance=self.context)
+ if isinstance(check, string_types):
+ return _(check)
+ return None
diff --git a/src/senaite/core/browser/static/assets/icons/multicomponent.svg b/src/senaite/core/browser/static/assets/icons/multicomponent.svg
new file mode 100644
index 0000000000..02a6f3e522
--- /dev/null
+++ b/src/senaite/core/browser/static/assets/icons/multicomponent.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/senaite/core/browser/static/css/bika.lims.analysisrequest.add.css b/src/senaite/core/browser/static/css/bika.lims.analysisrequest.add.css
index bbd1dc5bb7..2aa696b836 100755
--- a/src/senaite/core/browser/static/css/bika.lims.analysisrequest.add.css
+++ b/src/senaite/core/browser/static/css/bika.lims.analysisrequest.add.css
@@ -148,3 +148,9 @@
display:none;
}
/* /Service conditions */
+
+/* Service analytes */
+.ar-table tr td.service-column div.service-analytes {
+ display:block;
+}
+/* /Service analytes */
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 bab2295398..b05e39b6bc 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
@@ -11,6 +11,8 @@
window.AnalysisRequestAdd = (function() {
function AnalysisRequestAdd() {
+ this.set_service_analytes = bind(this.set_service_analytes, this);
+ this.init_service_analytes = bind(this.init_service_analytes, this);
this.init_service_conditions = bind(this.init_service_conditions, this);
this.copy_service_conditions = bind(this.copy_service_conditions, this);
this.set_service_conditions = bind(this.set_service_conditions, this);
@@ -69,6 +71,7 @@
this.get_flush_settings();
this.recalculate_records();
this.init_service_conditions();
+ this.init_service_analytes();
this.recalculate_prices();
return this;
};
@@ -624,6 +627,7 @@
el.closest("tr").addClass("visible");
}
me.set_service_conditions(el);
+ me.set_service_analytes(el);
return $(this).trigger("services:changed");
};
@@ -900,6 +904,7 @@
uid = $el.val();
console.debug("°°° on_analysis_click::UID=" + uid + " checked=" + checked + "°°°");
me.set_service_conditions($el);
+ me.set_service_analytes($el);
$(me).trigger("form:changed");
return $(me).trigger("services:changed");
};
@@ -1015,7 +1020,8 @@
if (is_service) {
uid = $el.closest("[uid]").attr("uid");
me.set_service_conditions($(_el));
- return me.copy_service_conditions(0, arnum, uid);
+ me.copy_service_conditions(0, arnum, uid);
+ return me.set_service_analytes($(_el));
}
});
if (is_service) {
@@ -1491,6 +1497,64 @@
});
};
+ AnalysisRequestAdd.prototype.init_service_analytes = function() {
+
+ /*
+ * Updates the visibility of the analytes for the selected services
+ */
+ var me, services;
+ console.debug("init_service_analytes");
+ me = this;
+ services = $("input[type=checkbox].analysisservice-cb:checked");
+ return $(services).each(function(idx, el) {
+ var $el;
+ $el = $(el);
+ return me.set_service_analytes($el);
+ });
+ };
+
+ AnalysisRequestAdd.prototype.set_service_analytes = function(el) {
+
+ /*
+ * Shows or hides the service analytes checkboxes for the (multi-component)
+ * service bound to the checkbox element passed-in
+ */
+ var analytes, arnum, base_info, checked, context, data, parent, template, uid;
+ checked = el.prop("checked");
+ parent = el.closest("td[uid][arnum]");
+ uid = parent.attr("uid");
+ arnum = parent.attr("arnum");
+ analytes = $("div.service-analytes", parent);
+ analytes.empty();
+ if (!checked) {
+ analytes.hide();
+ return;
+ }
+ data = analytes.data("data");
+ base_info = {
+ arnum: arnum
+ };
+ if (!data) {
+ return this.get_service(uid).done(function(data) {
+ var context, template;
+ context = $.extend({}, data, base_info);
+ if (context.analytes && context.analytes.length > 0) {
+ template = this.render_template("service-analytes", context);
+ analytes.append(template);
+ analytes.data("data", context);
+ return analytes.show();
+ }
+ });
+ } else {
+ context = $.extend({}, data, base_info);
+ if (context.analytes && context.analytes.length > 0) {
+ template = this.render_template("service-analytes", context);
+ analytes.append(template);
+ return analytes.show();
+ }
+ }
+ };
+
return AnalysisRequestAdd;
})();
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 1588e23b2a..41be4b8af4 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
@@ -52,6 +52,9 @@ class window.AnalysisRequestAdd
# initialize service conditions (needed for AR copies)
@init_service_conditions()
+ # initialize service analytes
+ @init_service_analytes()
+
# always recalculate prices in the first run
@recalculate_prices()
@@ -608,6 +611,8 @@ class window.AnalysisRequestAdd
el.closest("tr").addClass "visible"
# show/hide the service conditions for this analysis
me.set_service_conditions el
+ # show/hide the service analytes for this analysis
+ me.set_service_analytes el
# trigger event for price recalculation
$(@).trigger "services:changed"
@@ -895,6 +900,8 @@ class window.AnalysisRequestAdd
# show/hide the service conditions for this analysis
me.set_service_conditions $el
+ # show/hide the service analytes for this analysis
+ me.set_service_analytes $el
# trigger form:changed event
$(me).trigger "form:changed"
# trigger event for price recalculation
@@ -1021,6 +1028,8 @@ class window.AnalysisRequestAdd
me.set_service_conditions $(_el)
# copy the conditions for this analysis
me.copy_service_conditions 0, arnum, uid
+ # show/hide the service analytes for this analysis
+ me.set_service_analytes $(_el)
# trigger event for price recalculation
if is_service
@@ -1456,3 +1465,62 @@ class window.AnalysisRequestAdd
$(services).each (idx, el) ->
$el = $(el)
me.set_service_conditions $el
+
+
+ init_service_analytes: =>
+ ###
+ * Updates the visibility of the analytes for the selected services
+ ###
+ console.debug "init_service_analytes"
+
+ me = this
+
+ # Find out all selected services checkboxes
+ services = $("input[type=checkbox].analysisservice-cb:checked")
+ $(services).each (idx, el) ->
+ $el = $(el)
+ me.set_service_analytes $el
+
+
+ set_service_analytes: (el) =>
+ ###
+ * Shows or hides the service analytes checkboxes for the (multi-component)
+ * service bound to the checkbox element passed-in
+ ###
+
+ # Check whether the checkbox is selected or not
+ checked = el.prop "checked"
+
+ # Get the uid of the analysis and the column number
+ parent = el.closest("td[uid][arnum]")
+ uid = parent.attr "uid"
+ arnum = parent.attr "arnum"
+
+ # Get the div where service analytes are rendered
+ analytes = $("div.service-analytes", parent)
+ analytes.empty()
+
+ # If the service is unchecked, remove the analytes form
+ if not checked
+ analytes.hide()
+ return
+
+ # Check if this service has analytes
+ data = analytes.data "data"
+ base_info =
+ arnum: arnum
+
+ if not data
+ @get_service(uid).done (data) ->
+ context = $.extend({}, data, base_info)
+ if context.analytes and context.analytes.length > 0
+ template = @render_template "service-analytes", context
+ analytes.append template
+ analytes.data "data", context
+ analytes.show()
+ else
+ context = $.extend({}, data, base_info)
+ if context.analytes and context.analytes.length > 0
+ template = @render_template "service-analytes", context
+ analytes.append template
+ analytes.show()
diff --git a/src/senaite/core/catalog/analysis_catalog.py b/src/senaite/core/catalog/analysis_catalog.py
index 9784a794ee..69d3361ec8 100644
--- a/src/senaite/core/catalog/analysis_catalog.py
+++ b/src/senaite/core/catalog/analysis_catalog.py
@@ -47,6 +47,7 @@
("getSampleTypeUID", "", "FieldIndex"),
("getServiceUID", "", "FieldIndex"),
("getWorksheetUID", "", "FieldIndex"),
+ ("isAnalyte", "", "BooleanIndex"),
("isSampleReceived", "", "BooleanIndex"),
("sortable_title", "", "FieldIndex"),
]
diff --git a/src/senaite/core/catalog/indexer/baseanalysis.py b/src/senaite/core/catalog/indexer/baseanalysis.py
index 5bdee8dd78..71e181259a 100644
--- a/src/senaite/core/catalog/indexer/baseanalysis.py
+++ b/src/senaite/core/catalog/indexer/baseanalysis.py
@@ -19,6 +19,7 @@
# Some rights reserved, see README and LICENSE.
from bika.lims import api
+from bika.lims.content.abstractanalysis import AbstractAnalysis
from bika.lims.interfaces import IBaseAnalysis
from plone.indexer import indexer
from Products.CMFPlone.utils import safe_callable
@@ -30,4 +31,18 @@ def sortable_title(instance):
title = sortable_sortkey_title(instance)
if safe_callable(title):
title = title()
+
+ # if analyte, keep them sorted as they were defined in the service by user,
+ # but prepend multi-component's sortable title to ensure that multi is
+ # always returned first to make things easier
+ if isinstance(instance, AbstractAnalysis):
+ multi = instance.getMultiComponentAnalysis()
+ if multi:
+ title = api.get_title(instance)
+ service = multi.getAnalysisService()
+ analytes = service.getAnalytes()
+ titles = filter(None, [an.get("title") for an in analytes])
+ index = titles.index(title) if title in titles else len(titles)
+ title = "{}-{:04d}".format(sortable_title(multi)(), index)
+
return "{}-{}".format(title, api.get_id(instance))
diff --git a/src/senaite/core/profiles/default/metadata.xml b/src/senaite/core/profiles/default/metadata.xml
index ee5498c103..8794f5db2c 100644
--- a/src/senaite/core/profiles/default/metadata.xml
+++ b/src/senaite/core/profiles/default/metadata.xml
@@ -1,6 +1,6 @@
- 2613
+ 2614profile-Products.ATContentTypes:baseprofile-Products.CMFEditions:CMFEditions
diff --git a/src/senaite/core/tests/doctests/MultiComponentAnalysis.rst b/src/senaite/core/tests/doctests/MultiComponentAnalysis.rst
new file mode 100644
index 0000000000..40630414e6
--- /dev/null
+++ b/src/senaite/core/tests/doctests/MultiComponentAnalysis.rst
@@ -0,0 +1,501 @@
+Multiple component analysis
+---------------------------
+
+Multiple component analyses allow to measure multiple chemical analytes
+simultaneously with a single analyzer, without using filters or moving parts.
+
+Running this test from the buildout directory:
+
+ bin/test test_textual_doctests -t MultiComponentAnalysis
+
+
+Test Setup
+..........
+
+Needed Imports:
+
+ >>> from DateTime import DateTime
+ >>> from bika.lims import api
+ >>> from bika.lims.utils.analysisrequest import create_analysisrequest
+ >>> from bika.lims.workflow import doActionFor as do_action_for
+ >>> from bika.lims.workflow import isTransitionAllowed
+ >>> from plone.app.testing import TEST_USER_ID
+ >>> from plone.app.testing import setRoles
+
+Functional Helpers:
+
+ >>> def new_sample(client, contact, sample_type, services):
+ ... values = {
+ ... 'Client': api.get_uid(client),
+ ... 'Contact': api.get_uid(contact),
+ ... 'DateSampled': DateTime().strftime("%Y-%m-%d"),
+ ... 'SampleType': api.get_uid(sample_type),
+ ... }
+ ... uids = map(api.get_uid, services)
+ ... sample = create_analysisrequest(client, request, values, uids)
+ ... return sample
+
+ >>> def do_action(object, transition_id):
+ ... return do_action_for(object, transition_id)[0]
+
+Variables:
+
+ >>> portal = self.portal
+ >>> request = self.request
+ >>> setup = api.get_setup()
+
+We need to create some basic objects for the test:
+
+ >>> setRoles(portal, TEST_USER_ID, ['LabManager',])
+ >>> client = api.create(portal.clients, "Client", Name="Happy Hills", ClientID="HH", MemberDiscountApplies=True)
+ >>> contact = api.create(client, "Contact", Firstname="Rita", Lastname="Mohale")
+ >>> sample_type = api.create(setup.bika_sampletypes, "SampleType", title="Water", Prefix="W")
+ >>> lab_contact = api.create(setup.bika_labcontacts, "LabContact", Firstname="Lab", Lastname="Manager")
+ >>> department = api.create(setup.bika_departments, "Department", title="Chemistry", Manager=lab_contact)
+ >>> category = api.create(setup.bika_analysiscategories, "AnalysisCategory", title="Metals", Department=department)
+ >>> setup.setSelfVerificationEnabled(True)
+
+
+Multi-component Service
+.......................
+
+Create the multi-component service, that is made of analytes:
+
+ >>> analytes = [
+ ... {"keyword": "Pb", "title": "Lead"},
+ ... {"keyword": "Hg", "title": "Mercury"},
+ ... {"keyword": "As", "title": "Arsenic"},
+ ... {"keyword": "Cd", "title": "Cadmium"},
+ ... {"keyword": "Cu", "title": "Copper"},
+ ... {"keyword": "Ni", "title": "Nickel"},
+ ... {"keyword": "Zn", "title": "Zinc"},
+ ... ]
+ >>> metals = api.create(setup.bika_analysisservices, "AnalysisService",
+ ... title="ICP Metals", Keyword="Metals", Price="15",
+ ... Analytes=analytes, Category=category.UID())
+ >>> metals.isMultiComponent()
+ True
+
+
+Multi-component analysis
+........................
+
+Although there is only one "Multi-component" service, the system creates
+the analytes (from type "Analysis") automatically when the service is assigned
+to a Sample:
+
+ >>> sample = new_sample(client, contact, sample_type, [metals])
+ >>> analyses = sample.getAnalyses(full_objects=True)
+ >>> len(analyses)
+ 8
+
+The multi-component is always first and followed by the Analytes, with same
+order as defined in the Service:
+
+ >>> [an.getKeyword() for an in analyses]
+ ['Metals', 'Pb', 'Hg', 'As', 'Cd', 'Cu', 'Ni', 'Zn']
+
+From a multi-component analysis:
+
+ >>> multi_component = analyses[0]
+ >>> multi_component.isMultiComponent()
+ True
+
+ >>> multi_component.isAnalyte()
+ False
+
+one can extract its analytes as well:
+
+ >>> analytes = multi_component.getAnalytes()
+ >>> [an.getKeyword() for an in analytes]
+ ['Pb', 'Hg', 'As', 'Cd', 'Cu', 'Ni', 'Zn']
+
+ >>> analytes_uids = [an.UID() for an in analytes]
+ >>> analytes_uids == multi_component.getRawAnalytes()
+ True
+
+From an analyte, one can get the multi-component analysis that belongs to:
+
+ >>> pb = analytes[0]
+ >>> pb.isAnalyte()
+ True
+ >>> pb.isMultiComponent()
+ False
+ >>> multi_component == pb.getMultiComponentAnalysis()
+ True
+ >>> multi_component.UID() == pb.getRawMultiComponentAnalysis()
+ True
+
+
+Submission of results
+.....................
+
+Receive the sample:
+
+ >>> do_action(sample, "receive")
+ True
+
+Is not possible to set a result to a multi-component directly:
+
+ >>> multi_component.setResult("Something")
+ Traceback (most recent call last):
+ [...]
+ ValueError: setResult is not supported for Multi-component analyses
+
+But a "NA" (*No apply*) result is set automatically as soon as a result for
+any of its analytes is set:
+
+ >>> multi_component.getResult()
+ ''
+
+ >>> pb.setResult(12)
+ >>> multi_component.getResult()
+ 'NA'
+
+Is not possible to manually submit a multi-component analysis, is automatically
+submitted when results for all analytes are captured and submitted:
+
+ >>> isTransitionAllowed(multi_component, "submit")
+ False
+
+ >>> isTransitionAllowed(pb, "submit")
+ True
+
+ >>> api.get_review_status(multi_component)
+ 'unassigned'
+
+ >>> results = [an.setResult(12) for an in analytes]
+ >>> submitted = [do_action(an, "submit") for an in analytes]
+ >>> all(submitted)
+ True
+
+ >>> api.get_review_status(multi_component)
+ 'to_be_verified'
+
+
+Retraction of results
+.....................
+
+Create the sample, receive and capture results:
+
+ >>> sample = new_sample(client, contact, sample_type, [metals])
+ >>> success = do_action(sample, "receive")
+ >>> analyses = sample.getAnalyses(full_objects=True)
+ >>> multi_component = filter(lambda an: an.isMultiComponent(), analyses)[0]
+ >>> analytes = multi_component.getAnalytes()
+ >>> results = [an.setResult(12) for an in analytes]
+ >>> submitted = [do_action(an, "submit") for an in analytes]
+ >>> all(submitted)
+ True
+
+Analytes cannot be retracted, but the multi-component analysis only. The reason
+is that the retraction involves the creation of a retest. The detection of the
+concentrations of analytes in a multicomponent analysis takes place in a single
+analytical procedure, usually by an spectrometer. Thus, it does not make sense
+to create a retest for a single analyte - if there is an inconsistency, the
+whole multi-component analysis has to be run again:
+
+ >>> analyte = analytes[0]
+ >>> isTransitionAllowed(analyte, "retract")
+ False
+
+ >>> isTransitionAllowed(multi_component, "retract")
+ True
+
+When a multiple component analysis is retracted, a new multi-component test
+is added, with new analytes. Existing analytes and multi-component are all
+transitioned to "retracted" status:
+
+ >>> do_action(multi_component, "retract")
+ True
+
+ >>> api.get_review_status(multi_component)
+ 'retracted'
+
+ >>> list(set([api.get_review_status(an) for an in analytes]))
+ ['retracted']
+
+ >>> retest = multi_component.getRetest()
+ >>> retest.isMultiComponent()
+ True
+
+ >>> api.get_review_status(retest)
+ 'unassigned'
+
+ >>> retest_analytes = retest.getAnalytes()
+ >>> list(set([api.get_review_status(an) for an in retest_analytes]))
+ ['unassigned']
+
+
+Rejection of results
+....................
+
+Create the sample, receive and capture results:
+
+ >>> sample = new_sample(client, contact, sample_type, [metals])
+ >>> success = do_action(sample, "receive")
+ >>> analyses = sample.getAnalyses(full_objects=True)
+ >>> multi_component = filter(lambda an: an.isMultiComponent(), analyses)[0]
+ >>> analytes = multi_component.getAnalytes()
+ >>> results = [an.setResult(12) for an in analytes]
+ >>> submitted = [do_action(an, "submit") for an in analytes]
+ >>> all(submitted)
+ True
+
+Both individual analytes or the whole multi-component analysis can be rejected.
+Reason is that although a multi-component analysis takes place in a single
+run/analytical procedure, one might want to "discard" results for some of the
+analytes/components after the analysis has run without compromising the validity
+of the analytical process:
+
+ >>> analyte = analytes[0]
+ >>> isTransitionAllowed(analyte, "reject")
+ True
+
+ >>> isTransitionAllowed(multi_component, "reject")
+ True
+
+If I reject an analyte, the multi_component analysis is not affected:
+
+ >>> do_action(analyte, "reject")
+ True
+
+ >>> api.get_review_status(analyte)
+ 'rejected'
+
+ >>> api.get_review_status(multi_component)
+ 'to_be_verified'
+
+However, if I reject the multiple component analyses, all analytes are rejected
+automatically:
+
+ >>> statuses = list(set([api.get_review_status(an) for an in analytes]))
+ >>> sorted(statuses)
+ ['rejected', 'to_be_verified']
+
+ >>> do_action(multi_component, "reject")
+ True
+
+ >>> api.get_review_status(multi_component)
+ 'rejected'
+
+ >>> list(set([api.get_review_status(an) for an in analytes]))
+ ['rejected']
+
+
+Retest of multi-component analysis
+..................................
+
+Create the sample, receive and capture results:
+
+ >>> sample = new_sample(client, contact, sample_type, [metals])
+ >>> success = do_action(sample, "receive")
+ >>> analyses = sample.getAnalyses(full_objects=True)
+ >>> multi_component = filter(lambda an: an.isMultiComponent(), analyses)[0]
+ >>> analytes = multi_component.getAnalytes()
+ >>> results = [an.setResult(12) for an in analytes]
+ >>> submitted = [do_action(an, "submit") for an in analytes]
+ >>> all(submitted)
+ True
+
+Analytes cannot be retested, but the multi-component analysis only. The
+detection of the concentrations of analytes in a multi-component analysis takes
+place in a single analytical procedure. Therefore, it does not make sense to
+retest analytes individually, but the whole multi-component analysis:
+
+ >>> analyte = analytes[0]
+ >>> isTransitionAllowed(analyte, "retest")
+ False
+
+ >>> isTransitionAllowed(multi_component, "retest")
+ True
+
+When a multiple component analysis is retested, a new multi-component test
+is added, with new analytes. Existing analytes and multi-component are all
+transitioned to "verified" status:
+
+ >>> do_action(multi_component, "retest")
+ True
+
+ >>> api.get_review_status(multi_component)
+ 'verified'
+
+ >>> list(set([api.get_review_status(an) for an in analytes]))
+ ['verified']
+
+ >>> retest = multi_component.getRetest()
+ >>> retest.isMultiComponent()
+ True
+
+ >>> api.get_review_status(retest)
+ 'unassigned'
+
+ >>> retest_analytes = retest.getAnalytes()
+ >>> list(set([api.get_review_status(an) for an in retest_analytes]))
+ ['unassigned']
+
+
+Verification of multi-component analysis
+........................................
+
+Create the sample, receive and capture results:
+
+ >>> sample = new_sample(client, contact, sample_type, [metals])
+ >>> success = do_action(sample, "receive")
+ >>> analyses = sample.getAnalyses(full_objects=True)
+ >>> multi_component = filter(lambda an: an.isMultiComponent(), analyses)[0]
+ >>> analytes = multi_component.getAnalytes()
+ >>> results = [an.setResult(12) for an in analytes]
+ >>> submitted = [do_action(an, "submit") for an in analytes]
+ >>> all(submitted)
+ True
+
+Analytes cannot be verified, but the multi-component analysis only. The
+detection of the concentrations of analytes in a multi-component analysis takes
+place in a single analytical procedure. Therefore, it does not make sense to
+verify analytes individually, but the whole multi-component analysis:
+
+ >>> analyte = analytes[0]
+ >>> isTransitionAllowed(analyte, "verify")
+ False
+
+ >>> isTransitionAllowed(multi_component, "verify")
+ True
+
+When a multiple component analysis is verified, all analytes are automatically
+verified as well:
+
+ >>> do_action(multi_component, "verify")
+ True
+
+ >>> api.get_review_status(multi_component)
+ 'verified'
+
+ >>> list(set([api.get_review_status(an) for an in analytes]))
+ ['verified']
+
+
+Assignment of multi-component analysis
+......................................
+
+Create the sample and receive:
+
+ >>> sample = new_sample(client, contact, sample_type, [metals])
+ >>> success = do_action(sample, "receive")
+ >>> analyses = sample.getAnalyses(full_objects=True)
+ >>> multi_component = filter(lambda an: an.isMultiComponent(), analyses)[0]
+ >>> analytes = multi_component.getAnalytes()
+
+Status of multi-component and analytes is 'unassigned':
+
+ >>> api.get_review_status(multi_component)
+ 'unassigned'
+
+ >>> list(set([api.get_review_status(an) for an in analytes]))
+ ['unassigned']
+
+Create a worksheet:
+
+ >>> worksheet = api.create(portal.worksheets, "Worksheet")
+
+When a multi-component is assigned to a worksheet, the analytes are assigned
+as well:
+
+ >>> worksheet.addAnalyses([multi_component])
+ >>> multi_component.getWorksheet() == worksheet
+ True
+
+ >>> assigned = [analyte.getWorksheet() == worksheet for analyte in analytes]
+ >>> all(assigned)
+ True
+
+And all their statuses are now 'assigned':
+
+ >>> api.get_review_status(multi_component)
+ 'assigned'
+
+ >>> list(set([api.get_review_status(an) for an in analytes]))
+ ['assigned']
+
+
+Multi-component with default result
+...................................
+
+Set a default result for a Multi-component analysis:
+
+ >>> metals.setDefaultResult("12")
+
+Create a sample:
+
+ >>> sample = new_sample(client, contact, sample_type, [metals])
+ >>> analyses = sample.getAnalyses(full_objects=True)
+ >>> multi_component = filter(lambda an: an.isMultiComponent(), analyses)[0]
+ >>> analytes = multi_component.getAnalytes()
+
+Analytes have the default result set, but the multi-component:
+
+ >>> list(set([analyte.getResult() for analyte in analytes]))
+ ['12']
+
+ >>> multi_component.getResult()
+ 'NA'
+
+The Result Capture Date is not set in any case:
+
+ >>> filter(None, ([analyte.getResultCaptureDate() for an in analytes]))
+ []
+
+ >>> multi_component.getResultCaptureDate()
+
+Restore the default result for a Multi-component analysis:
+
+ >>> metals.setDefaultResult(None)
+
+
+Invalidation of samples with multi-component analyses
+.....................................................
+
+Create the sample, receive and submit:
+
+ >>> sample = new_sample(client, contact, sample_type, [metals])
+ >>> success = do_action(sample, "receive")
+ >>> analyses = sample.getAnalyses(full_objects=True)
+ >>> multi = filter(lambda an: an.isMultiComponent(), analyses)[0]
+ >>> analytes = multi.getAnalytes()
+ >>> results = [an.setResult(12) for an in analytes]
+ >>> submitted = [do_action(an, "submit") for an in analytes]
+
+Verifying the multi-component analysis leads the sample to verified status too:
+
+ >>> success = do_action(multi, "verify")
+ >>> api.get_review_status(sample)
+ 'verified'
+
+Invalidate the sample. The retest sample created automatically contains a copy
+of the original multi-component analysis, with analytes properly assigned:
+
+ >>> success = do_action(sample, "invalidate")
+ >>> api.get_review_status(sample)
+ 'invalid'
+
+ >>> retest = sample.getRetest()
+ >>> retests = retest.getAnalyses(full_objects=True)
+
+All analyses and analytes belong to the retest:
+
+ >>> all([an.getRequest() == retest for an in retests])
+ True
+
+ >>> multi = filter(lambda an: an.isMultiComponent(), retests)[0]
+ >>> multi.getRequest() == retest
+ True
+
+The analytes from the retest are all assigned to the new multi-component:
+
+ >>> multi_analytes = multi.getAnalytes()
+ >>> all([an.getMultiComponentAnalysis() == multi for an in multi_analytes])
+ True
+
+ >>> analytes = filter(lambda an: an.isAnalyte(), retests)
+ >>> all([an.getMultiComponentAnalysis() == multi for an in analytes])
+ True
diff --git a/src/senaite/core/upgrade/v02_06_000.py b/src/senaite/core/upgrade/v02_06_000.py
index cd8fd8bb75..64e9ffe287 100644
--- a/src/senaite/core/upgrade/v02_06_000.py
+++ b/src/senaite/core/upgrade/v02_06_000.py
@@ -32,6 +32,8 @@
from senaite.core.config import PROJECTNAME as product
from senaite.core.interfaces import IContentMigrator
from senaite.core.setuphandlers import add_senaite_setup_items
+from senaite.core.setuphandlers import setup_auditlog_catalog_mappings
+from senaite.core.setuphandlers import setup_catalog_mappings
from senaite.core.setuphandlers import setup_core_catalogs
from senaite.core.setuphandlers import setup_other_catalogs
from senaite.core.upgrade import upgradestep
@@ -694,3 +696,16 @@ def reindex_sampletype_uid(tool):
logger.info("Reindexing sampletype_uid index from setup ...")
reindex_index(SETUP_CATALOG, "sampletype_uid")
logger.info("Reindexing sampletype_uid index from setup [DONE]")
+
+
+def setup_catalogs(tool):
+ """Setup all core catalogs and ensure all indexes are present
+ """
+ logger.info("Setup Catalogs ...")
+ portal = api.get_portal()
+
+ setup_catalog_mappings(portal)
+ setup_core_catalogs(portal)
+ setup_auditlog_catalog_mappings(portal)
+
+ logger.info("Setup Catalogs [DONE]")
diff --git a/src/senaite/core/upgrade/v02_06_000.zcml b/src/senaite/core/upgrade/v02_06_000.zcml
index 96be845f0a..ad759c38b8 100644
--- a/src/senaite/core/upgrade/v02_06_000.zcml
+++ b/src/senaite/core/upgrade/v02_06_000.zcml
@@ -3,6 +3,14 @@
xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
i18n_domain="senaite.core">
+
+