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 + 2614 profile-Products.ATContentTypes:base profile-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"> + +