Passed
Pull Request — 2.x (#1857)
by Jordi
04:46
created

AbstractAnalysis.setResult()   D

Complexity

Conditions 13

Size

Total Lines 54
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 26
dl 0
loc 54
rs 4.2
c 0
b 0
f 0
cc 13
nop 2

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like bika.lims.content.abstractanalysis.AbstractAnalysis.setResult() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2021 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import cgi
22
import json
23
import math
24
from decimal import Decimal
25
26
from AccessControl import ClassSecurityInfo
27
from bika.lims import api
28
from bika.lims import bikaMessageFactory as _
29
from bika.lims import deprecated
30
from bika.lims import logger
31
from bika.lims import workflow as wf
32
from bika.lims.browser.fields import HistoryAwareReferenceField
33
from bika.lims.browser.fields import InterimFieldsField
34
from bika.lims.browser.fields import ResultRangeField
35
from bika.lims.browser.fields import UIDReferenceField
36
from bika.lims.browser.fields.uidreferencefield import get_backreferences
37
from bika.lims.browser.widgets import RecordsWidget
38
from bika.lims.config import LDL
39
from bika.lims.config import UDL
40
from bika.lims.content.abstractbaseanalysis import AbstractBaseAnalysis
41
from bika.lims.content.abstractbaseanalysis import schema
42
from bika.lims.interfaces import IDuplicateAnalysis
43
from bika.lims.permissions import FieldEditAnalysisResult
44
from bika.lims.utils import drop_trailing_zeros_decimal
45
from bika.lims.utils import formatDecimalMark
46
from bika.lims.utils.analysis import format_numeric_result
47
from bika.lims.utils.analysis import get_significant_digits
48
from bika.lims.workflow import getTransitionActor
49
from bika.lims.workflow import getTransitionDate
50
from DateTime import DateTime
51
from Products.Archetypes.Field import DateTimeField
52
from Products.Archetypes.Field import FixedPointField
53
from Products.Archetypes.Field import IntegerField
54
from Products.Archetypes.Field import StringField
55
from Products.Archetypes.references import HoldingReference
56
from Products.Archetypes.Schema import Schema
57
from Products.CMFCore.permissions import View
58
59
# A link directly to the AnalysisService object used to create the analysis
60
AnalysisService = UIDReferenceField(
61
    'AnalysisService'
62
)
63
64
# Attachments which are added manually in the UI, or automatically when
65
# results are imported from a file supplied by an instrument.
66
Attachment = UIDReferenceField(
67
    'Attachment',
68
    multiValued=1,
69
    allowed_types=('Attachment',)
70
)
71
72
# The final result of the analysis is stored here.  The field contains a
73
# String value, but the result itself is required to be numeric.  If
74
# a non-numeric result is needed, ResultOptions can be used.
75
Result = StringField(
76
    'Result',
77
    read_permission=View,
78
    write_permission=FieldEditAnalysisResult,
79
)
80
81
# When the result is changed, this value is updated to the current time.
82
# Only the most recent result capture date is recorded here and used to
83
# populate catalog values, however the workflow review_history can be
84
# used to get all dates of result capture
85
ResultCaptureDate = DateTimeField(
86
    'ResultCaptureDate'
87
)
88
89
# Returns the retracted analysis this analysis is a retest of
90
RetestOf = UIDReferenceField(
91
    'RetestOf'
92
)
93
94
# If the result is outside of the detection limits of the method or instrument,
95
# the operand (< or >) is stored here.  For routine analyses this is taken
96
# from the Result, if the result entered explicitly startswith "<" or ">"
97
DetectionLimitOperand = StringField(
98
    'DetectionLimitOperand',
99
    read_permission=View,
100
    write_permission=FieldEditAnalysisResult,
101
)
102
103
# The ID of the logged in user who submitted the result for this Analysis.
104
Analyst = StringField(
105
    'Analyst'
106
)
107
108
# The actual uncertainty for this analysis' result, populated from the ranges
109
# specified in the analysis service when the result is submitted.
110
Uncertainty = FixedPointField(
111
    'Uncertainty',
112
    read_permission=View,
113
    write_permission="Field: Edit Result",
114
    precision=10,
115
)
116
117
# transitioned to a 'verified' state. This value is set automatically
118
# when the analysis is created, based on the value set for the property
119
# NumberOfRequiredVerifications from the Analysis Service
120
NumberOfRequiredVerifications = IntegerField(
121
    'NumberOfRequiredVerifications',
122
    default=1
123
)
124
125
# Routine Analyses and Reference Analysis have a versioned link to
126
# the calculation at creation time.
127
Calculation = HistoryAwareReferenceField(
128
    'Calculation',
129
    read_permission=View,
130
    write_permission=FieldEditAnalysisResult,
131
    allowed_types=('Calculation',),
132
    relationship='AnalysisCalculation',
133
    referenceClass=HoldingReference
134
)
135
136
# InterimFields are defined in Calculations, Services, and Analyses.
137
# In Analysis Services, the default values are taken from Calculation.
138
# In Analyses, the default values are taken from the Analysis Service.
139
# When instrument results are imported, the values in analysis are overridden
140
# before the calculation is performed.
141
InterimFields = InterimFieldsField(
142
    'InterimFields',
143
    read_permission=View,
144
    write_permission=FieldEditAnalysisResult,
145
    schemata='Method',
146
    widget=RecordsWidget(
147
        label=_("Calculation Interim Fields"),
148
        description=_(
149
            "Values can be entered here which will override the defaults "
150
            "specified in the Calculation Interim Fields."),
151
    )
152
)
153
154
# Results Range that applies to this analysis
155
ResultsRange = ResultRangeField(
156
    "ResultsRange",
157
    required=0
158
)
159
160
schema = schema.copy() + Schema((
161
    AnalysisService,
162
    Analyst,
163
    Attachment,
164
    DetectionLimitOperand,
165
    # NumberOfRequiredVerifications overrides AbstractBaseClass
166
    NumberOfRequiredVerifications,
167
    Result,
168
    ResultCaptureDate,
169
    RetestOf,
170
    Uncertainty,
171
    Calculation,
172
    InterimFields,
173
    ResultsRange,
174
))
175
176
177
class AbstractAnalysis(AbstractBaseAnalysis):
178
    security = ClassSecurityInfo()
179
    displayContentsTab = False
180
    schema = schema
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable schema does not seem to be defined.
Loading history...
181
182
    @deprecated('[1705] Currently returns the Analysis object itself.  If you '
183
                'need to get the service, use getAnalysisService instead')
184
    @security.public
185
    def getService(self):
186
        return self
187
188
    def getServiceUID(self):
189
        """Return the UID of the associated service.
190
        """
191
        service = self.getAnalysisService()
192
        if service:
193
            return service.UID()
194
195
    @security.public
196
    def getNumberOfVerifications(self):
197
        return len(self.getVerificators())
198
199
    @security.public
200
    def getNumberOfRemainingVerifications(self):
201
        required = self.getNumberOfRequiredVerifications()
202
        done = self.getNumberOfVerifications()
203
        if done >= required:
204
            return 0
205
        return required - done
206
207
    # TODO Workflow - analysis . Remove?
208
    @security.public
209
    def getLastVerificator(self):
210
        verifiers = self.getVerificators()
211
        return verifiers and verifiers[-1] or None
212
213
    @security.public
214
    def getVerificators(self):
215
        """Returns the user ids of the users that verified this analysis
216
        """
217
        verifiers = list()
218
        actions = ["verify", "multi_verify"]
219
        for event in wf.getReviewHistory(self):
220
            if event['action'] in actions:
221
                verifiers.append(event['actor'])
222
        sorted(verifiers, reverse=True)
223
        return verifiers
224
225
    @security.public
226
    def getDefaultUncertainty(self, result=None):
227
        """Return the uncertainty value, if the result falls within
228
        specified ranges for the service from which this analysis was derived.
229
        """
230
231
        if result is None:
232
            result = self.getResult()
233
234
        uncertainties = self.getUncertainties()
235
        if uncertainties:
236
            try:
237
                res = float(result)
238
            except (TypeError, ValueError):
239
                # if analysis result is not a number, then we assume in range
240
                return None
241
242
            for d in uncertainties:
243
                _min = float(d['intercept_min'])
244
                _max = float(d['intercept_max'])
245
                if _min <= res and res <= _max:
246
                    if str(d['errorvalue']).strip().endswith('%'):
247
                        try:
248
                            percvalue = float(d['errorvalue'].replace('%', ''))
249
                        except ValueError:
250
                            return None
251
                        uncertainty = res / 100 * percvalue
252
                    else:
253
                        uncertainty = float(d['errorvalue'])
254
255
                    return uncertainty
256
        return None
257
258
    @security.public
259
    def getUncertainty(self, result=None):
260
        """Returns the uncertainty for this analysis and result.
261
        Returns the value from Schema's Uncertainty field if the Service has
262
        the option 'Allow manual uncertainty'. Otherwise, do a callback to
263
        getDefaultUncertainty(). Returns None if no result specified and the
264
        current result for this analysis is below or above detections limits.
265
        """
266
        uncertainty = self.getField('Uncertainty').get(self)
267
        if result is None and (self.isAboveUpperDetectionLimit() or
268
                               self.isBelowLowerDetectionLimit()):
269
            return None
270
271
        if uncertainty and self.getAllowManualUncertainty() is True:
272
            try:
273
                uncertainty = float(uncertainty)
274
                return uncertainty
275
            except (TypeError, ValueError):
276
                # if uncertainty is not a number, return default value
277
                pass
278
        return self.getDefaultUncertainty(result)
279
280
    @security.public
281
    def setUncertainty(self, unc):
282
        """Sets the uncertainty for this analysis. If the result is a
283
        Detection Limit or the value is below LDL or upper UDL, sets the
284
        uncertainty value to 0
285
        """
286
        # Uncertainty calculation on DL
287
        # https://jira.bikalabs.com/browse/LIMS-1808
288
        if self.isAboveUpperDetectionLimit() or \
289
                self.isBelowLowerDetectionLimit():
290
            self.getField('Uncertainty').set(self, None)
291
        else:
292
            self.getField('Uncertainty').set(self, unc)
293
294
    @security.public
295
    def setDetectionLimitOperand(self, value):
296
        """Set detection limit operand for this analysis
297
        Allowed detection limit operands are `<` and `>`.
298
        """
299
        manual_dl = self.getAllowManualDetectionLimit()
300
        selector = self.getDetectionLimitSelector()
301
        if not manual_dl and not selector:
302
            # Don't allow the user to set the limit operand if manual assignment
303
            # is not allowed and selector is not visible
304
            return
305
306
        # Changing the detection limit operand has a side effect on the result
307
        result = self.getResult()
308
        if value in [LDL, UDL]:
309
            # flush uncertainty
310
            self.setUncertainty("")
311
312
            # If no previous result or user is not allowed to manually set the
313
            # the detection limit, override the result with default LDL/UDL
314
            has_result = api.is_floatable(result)
315
            if not has_result or not manual_dl:
316
                # set the result according to the system default UDL/LDL values
317
                if value == LDL:
318
                    result = self.getLowerDetectionLimit()
319
                else:
320
                    result = self.getUpperDetectionLimit()
321
322
        else:
323
            value = ""
324
            # Restore the DetectionLimitSelector, cause maybe its visibility
325
            # was changed because allow manual detection limit was enabled and
326
            # the user set a result with "<" or ">"
327
            if manual_dl:
328
                service = self.getAnalysisService()
329
                selector = service.getDetectionLimitSelector()
330
                self.setDetectionLimitSelector(selector)
331
332
        # Set the result
333
        self.getField("Result").set(self, result)
334
335
        # Set the detection limit to the field
336
        self.getField("DetectionLimitOperand").set(self, value)
337
338
    # Method getLowerDetectionLimit overrides method of class BaseAnalysis
339
    @security.public
340
    def getLowerDetectionLimit(self):
341
        """Returns the Lower Detection Limit (LDL) that applies to this
342
        analysis in particular. If no value set or the analysis service
343
        doesn't allow manual input of detection limits, returns the value set
344
        by default in the Analysis Service
345
        """
346
        if self.isLowerDetectionLimit():
347
            result = self.getResult()
348
            try:
349
                # in this case, the result itself is the LDL.
350
                return float(result)
351
            except (TypeError, ValueError):
352
                logger.warn("The result for the analysis %s is a lower "
353
                            "detection limit, but not floatable: '%s'. "
354
                            "Returnig AS's default LDL." %
355
                            (self.id, result))
356
        return AbstractBaseAnalysis.getLowerDetectionLimit(self)
357
358
    # Method getUpperDetectionLimit overrides method of class BaseAnalysis
359
    @security.public
360
    def getUpperDetectionLimit(self):
361
        """Returns the Upper Detection Limit (UDL) that applies to this
362
        analysis in particular. If no value set or the analysis service
363
        doesn't allow manual input of detection limits, returns the value set
364
        by default in the Analysis Service
365
        """
366
        if self.isUpperDetectionLimit():
367
            result = self.getResult()
368
            try:
369
                # in this case, the result itself is the LDL.
370
                return float(result)
371
            except (TypeError, ValueError):
372
                logger.warn("The result for the analysis %s is a lower "
373
                            "detection limit, but not floatable: '%s'. "
374
                            "Returnig AS's default LDL." %
375
                            (self.id, result))
376
        return AbstractBaseAnalysis.getUpperDetectionLimit(self)
377
378
    @security.public
379
    def isBelowLowerDetectionLimit(self):
380
        """Returns True if the result is below the Lower Detection Limit or
381
        if Lower Detection Limit has been manually set
382
        """
383
        if self.isLowerDetectionLimit():
384
            return True
385
386
        result = self.getResult()
387
        if result and str(result).strip().startswith(LDL):
388
            return True
389
390
        if api.is_floatable(result):
391
            return api.to_float(result) < self.getLowerDetectionLimit()
392
393
        return False
394
395
    @security.public
396
    def isAboveUpperDetectionLimit(self):
397
        """Returns True if the result is above the Upper Detection Limit or
398
        if Upper Detection Limit has been manually set
399
        """
400
        if self.isUpperDetectionLimit():
401
            return True
402
403
        result = self.getResult()
404
        if result and str(result).strip().startswith(UDL):
405
            return True
406
407
        if api.is_floatable(result):
408
            return api.to_float(result) > self.getUpperDetectionLimit()
409
410
        return False
411
412
    @security.public
413
    def getDetectionLimits(self):
414
        """Returns a two-value array with the limits of detection (LDL and
415
        UDL) that applies to this analysis in particular. If no value set or
416
        the analysis service doesn't allow manual input of detection limits,
417
        returns the value set by default in the Analysis Service
418
        """
419
        return [self.getLowerDetectionLimit(), self.getUpperDetectionLimit()]
420
421
    @security.public
422
    def isLowerDetectionLimit(self):
423
        """Returns True if the result for this analysis represents a Lower
424
        Detection Limit. Otherwise, returns False
425
        """
426
        return self.getDetectionLimitOperand() == LDL
427
428
    @security.public
429
    def isUpperDetectionLimit(self):
430
        """Returns True if the result for this analysis represents an Upper
431
        Detection Limit. Otherwise, returns False
432
        """
433
        return self.getDetectionLimitOperand() == UDL
434
435
    @security.public
436
    def getDependents(self):
437
        """Return a list of analyses who depend on us to calculate their result
438
        """
439
        raise NotImplementedError("getDependents is not implemented.")
440
441
    @security.public
442
    def getDependencies(self, with_retests=False):
443
        """Return a list of siblings who we depend on to calculate our result.
444
        :param with_retests: If false, siblings with retests are dismissed
445
        :type with_retests: bool
446
        :return: Analyses the current analysis depends on
447
        :rtype: list of IAnalysis
448
        """
449
        raise NotImplementedError("getDependencies is not implemented.")
450
451
    @security.public
452
    def setResult(self, value):
453
        """Validate and set a value into the Result field, taking into
454
        account the Detection Limits.
455
        :param value: is expected to be a string.
456
        """
457
        # Always update ResultCapture date when this field is modified
458
        self.setResultCaptureDate(DateTime())
459
460
        # Convert to list ff the analysis has result options set with multi
461
        if self.getResultOptions() and "multi" in self.getResultOptionsType():
462
            if not isinstance(value, (list, tuple)):
463
                value = filter(None, [value])
464
465
        # Handle list results
466
        if isinstance(value, (list, tuple)):
467
            value = json.dumps(value)
468
469
        # Ensure result integrity regards to None, empty and 0 values
470
        val = str("" if not value and value != 0 else value).strip()
471
472
        # UDL/LDL directly entered in the results field
473
        if val and val[0] in [LDL, UDL]:
474
            # Result prefixed with LDL/UDL
475
            oper = val[0]
476
            # Strip off LDL/UDL from the result
477
            val = val.replace(oper, "", 1)
478
            # Check if the value is indeterminate / non-floatable
479
            try:
480
                val = float(val)
481
            except (ValueError, TypeError):
482
                val = value
483
484
            # We dismiss the operand and the selector visibility unless the user
485
            # is allowed to manually set the detection limit or the DL selector
486
            # is visible.
487
            allow_manual = self.getAllowManualDetectionLimit()
488
            selector = self.getDetectionLimitSelector()
489
            if allow_manual or selector:
490
                # Ensure visibility of the detection limit selector
491
                self.setDetectionLimitSelector(True)
492
493
                # Set the detection limit operand
494
                self.setDetectionLimitOperand(oper)
495
496
                if not allow_manual:
497
                    # Override value by default DL
498
                    if oper == LDL:
499
                        val = self.getLowerDetectionLimit()
500
                    else:
501
                        val = self.getUpperDetectionLimit()
502
503
        # Set the result field
504
        self.getField("Result").set(self, val)
505
506
    @security.public
507
    def calculateResult(self, override=False, cascade=False):
508
        """Calculates the result for the current analysis if it depends of
509
        other analysis/interim fields. Otherwise, do nothing
510
        """
511
        if self.getResult() and override is False:
512
            return False
513
514
        calc = self.getCalculation()
515
        if not calc:
516
            return False
517
518
        mapping = {}
519
520
        # Interims' priority order (from low to high):
521
        # Calculation < Analysis
522
        interims = calc.getInterimFields() + self.getInterimFields()
523
524
        # Add interims to mapping
525
        for i in interims:
526
            if 'keyword' not in i:
527
                continue
528
            # skip unset values
529
            if i['value'] == '':
530
                continue
531
            try:
532
                ivalue = float(i['value'])
533
                mapping[i['keyword']] = ivalue
534
            except (TypeError, ValueError):
535
                # Interim not float, abort
536
                return False
537
538
        # Add dependencies results to mapping
539
        dependencies = self.getDependencies()
540
        for dependency in dependencies:
541
            result = dependency.getResult()
542
            if not result:
543
                # Dependency without results found
544
                if cascade:
545
                    # Try to calculate the dependency result
546
                    dependency.calculateResult(override, cascade)
547
                    result = dependency.getResult()
548
                else:
549
                    return False
550
            if result:
551
                try:
552
                    result = float(str(result))
553
                    key = dependency.getKeyword()
554
                    ldl = dependency.getLowerDetectionLimit()
555
                    udl = dependency.getUpperDetectionLimit()
556
                    bdl = dependency.isBelowLowerDetectionLimit()
557
                    adl = dependency.isAboveUpperDetectionLimit()
558
                    mapping[key] = result
559
                    mapping['%s.%s' % (key, 'RESULT')] = result
560
                    mapping['%s.%s' % (key, 'LDL')] = ldl
561
                    mapping['%s.%s' % (key, 'UDL')] = udl
562
                    mapping['%s.%s' % (key, 'BELOWLDL')] = int(bdl)
563
                    mapping['%s.%s' % (key, 'ABOVEUDL')] = int(adl)
564
                except (TypeError, ValueError):
565
                    return False
566
567
        # Calculate
568
        formula = calc.getMinifiedFormula()
569
        formula = formula.replace('[', '%(').replace(']', ')f')
570
        try:
571
            formula = eval("'%s'%%mapping" % formula,
572
                           {"__builtins__": None,
573
                            'math': math,
574
                            'context': self},
575
                           {'mapping': mapping})
576
            result = eval(formula, calc._getGlobals())
577
        except TypeError:
578
            self.setResult("NA")
579
            return True
580
        except ZeroDivisionError:
581
            self.setResult('0/0')
582
            return True
583
        except KeyError:
584
            self.setResult("NA")
585
            return True
586
        except ImportError:
587
            self.setResult("NA")
588
            return True
589
590
        self.setResult(str(result))
591
        return True
592
593
    @security.public
594
    def getPrice(self):
595
        """The function obtains the analysis' price without VAT and without
596
        member discount
597
        :return: the price (without VAT or Member Discount) in decimal format
598
        """
599
        analysis_request = self.aq_parent
600
        client = analysis_request.aq_parent
601
        if client.getBulkDiscount():
602
            price = self.getBulkPrice()
603
        else:
604
            price = self.getField('Price').get(self)
605
        return price
606
607
    @security.public
608
    def getVATAmount(self):
609
        """Compute the VAT amount without member discount.
610
        :return: the result as a float
611
        """
612
        vat = self.getVAT()
613
        price = self.getPrice()
614
        return Decimal(price) * Decimal(vat) / 100
615
616
    @security.public
617
    def getTotalPrice(self):
618
        """Obtain the total price without client's member discount. The function
619
        keeps in mind the client's bulk discount.
620
        :return: the result as a float
621
        """
622
        return Decimal(self.getPrice()) + Decimal(self.getVATAmount())
623
624
    @security.public
625
    def getDuration(self):
626
        """Returns the time in minutes taken for this analysis.
627
        If the analysis is not yet 'ready to process', returns 0
628
        If the analysis is still in progress (not yet verified),
629
            duration = date_verified - date_start_process
630
        Otherwise:
631
            duration = current_datetime - date_start_process
632
        :return: time in minutes taken for this analysis
633
        :rtype: int
634
        """
635
        starttime = self.getStartProcessDate()
636
        if not starttime:
637
            # The analysis is not yet ready to be processed
638
            return 0
639
        endtime = self.getDateVerified() or DateTime()
640
641
        # Duration in minutes
642
        duration = (endtime - starttime) * 24 * 60
643
        return duration
644
645
    @security.public
646
    def getEarliness(self):
647
        """The remaining time in minutes for this analysis to be completed.
648
        Returns zero if the analysis is neither 'ready to process' nor a
649
        turnaround time is set.
650
            earliness = duration - max_turnaround_time
651
        The analysis is late if the earliness is negative
652
        :return: the remaining time in minutes before the analysis reaches TAT
653
        :rtype: int
654
        """
655
        maxtime = self.getMaxTimeAllowed()
656
        if not maxtime:
657
            # No Turnaround time is set for this analysis
658
            return 0
659
        return api.to_minutes(**maxtime) - self.getDuration()
660
661
    @security.public
662
    def isLateAnalysis(self):
663
        """Returns true if the analysis is late in accordance with the maximum
664
        turnaround time. If no maximum turnaround time is set for this analysis
665
        or it is not yet ready to be processed, or there is still time
666
        remaining (earliness), returns False.
667
        :return: true if the analysis is late
668
        :rtype: bool
669
        """
670
        return self.getEarliness() < 0
671
672
    @security.public
673
    def getLateness(self):
674
        """The time in minutes that exceeds the maximum turnaround set for this
675
        analysis. If the analysis has no turnaround time set or is not ready
676
        for process yet, returns 0. The analysis is not late if the lateness is
677
        negative
678
        :return: the time in minutes that exceeds the maximum turnaround time
679
        :rtype: int
680
        """
681
        return -self.getEarliness()
682
683
    @security.public
684
    def isInstrumentAllowed(self, instrument):
685
        """Checks if the specified instrument can be set for this analysis,
686
687
        :param instrument: string,Instrument
688
        :return: True if the assignment of the passed in instrument is allowed
689
        :rtype: bool
690
        """
691
        uid = api.get_uid(instrument)
692
        return uid in map(api.get_uid, self.getAllowedInstruments())
693
694
    @security.public
695
    def isMethodAllowed(self, method):
696
        """Checks if the analysis can follow the method specified
697
698
        :param method: string,Method
699
        :return: True if the analysis can follow the method specified
700
        :rtype: bool
701
        """
702
        uid = api.get_uid(method)
703
        return uid in map(api.get_uid, self.getAllowedMethods())
704
705
    @security.public
706
    def getAllowedMethods(self):
707
        """Returns the allowed methods for this analysis, either if the method
708
        was assigned directly (by using "Allows manual entry of results") or
709
        indirectly via Instrument ("Allows instrument entry of results") in
710
        Analysis Service Edit View.
711
        :return: A list with the methods allowed for this analysis
712
        :rtype: list of Methods
713
        """
714
        service = self.getAnalysisService()
715
        if not service:
716
            return []
717
        # get the available methods of the service
718
        return service.getMethods()
719
720
    @security.public
721
    def getAllowedInstruments(self):
722
        """Returns the allowed instruments from the service
723
724
        :return: A list of instruments allowed for this Analysis
725
        :rtype: list of instruments
726
        """
727
        service = self.getAnalysisService()
728
        if not service:
729
            return []
730
        return service.getInstruments()
731
732
    @security.public
733
    def getExponentialFormatPrecision(self, result=None):
734
        """ Returns the precision for the Analysis Service and result
735
        provided. Results with a precision value above this exponential
736
        format precision should be formatted as scientific notation.
737
738
        If the Calculate Precision according to Uncertainty is not set,
739
        the method will return the exponential precision value set in the
740
        Schema. Otherwise, will calculate the precision value according to
741
        the Uncertainty and the result.
742
743
        If Calculate Precision from the Uncertainty is set but no result
744
        provided neither uncertainty values are set, returns the fixed
745
        exponential precision.
746
747
        Will return positive values if the result is below 0 and will return
748
        0 or positive values if the result is above 0.
749
750
        Given an analysis service with fixed exponential format
751
        precision of 4:
752
        Result      Uncertainty     Returns
753
        5.234           0.22           0
754
        13.5            1.34           1
755
        0.0077          0.008         -3
756
        32092           0.81           4
757
        456021          423            5
758
759
        For further details, visit https://jira.bikalabs.com/browse/LIMS-1334
760
761
        :param result: if provided and "Calculate Precision according to the
762
        Uncertainty" is set, the result will be used to retrieve the
763
        uncertainty from which the precision must be calculated. Otherwise,
764
        the fixed-precision will be used.
765
        :returns: the precision
766
        """
767
        if not result or self.getPrecisionFromUncertainty() is False:
768
            return self._getExponentialFormatPrecision()
769
        else:
770
            uncertainty = self.getUncertainty(result)
771
            if uncertainty is None:
772
                return self._getExponentialFormatPrecision()
773
774
            try:
775
                float(result)
776
            except ValueError:
777
                # if analysis result is not a number, then we assume in range
778
                return self._getExponentialFormatPrecision()
779
780
            return get_significant_digits(uncertainty)
781
782
    def _getExponentialFormatPrecision(self):
783
        field = self.getField('ExponentialFormatPrecision')
784
        value = field.get(self)
785
        if value is None:
786
            # https://github.com/bikalims/bika.lims/issues/2004
787
            # We require the field, because None values make no sense at all.
788
            value = self.Schema().getField(
789
                'ExponentialFormatPrecision').getDefault(self)
790
        return value
791
792
    @security.public
793
    def getFormattedResult(self, specs=None, decimalmark='.', sciformat=1,
794
                           html=True):
795
        """Formatted result:
796
        1. If the result is a detection limit, returns '< LDL' or '> UDL'
797
        2. Print ResultText of matching ResultOptions
798
        3. If the result is not floatable, return it without being formatted
799
        4. If the analysis specs has hidemin or hidemax enabled and the
800
           result is out of range, render result as '<min' or '>max'
801
        5. If the result is below Lower Detection Limit, show '<LDL'
802
        6. If the result is above Upper Detecion Limit, show '>UDL'
803
        7. Otherwise, render numerical value
804
        :param specs: Optional result specifications, a dictionary as follows:
805
            {'min': <min_val>,
806
             'max': <max_val>,
807
             'error': <error>,
808
             'hidemin': <hidemin_val>,
809
             'hidemax': <hidemax_val>}
810
        :param decimalmark: The string to be used as a decimal separator.
811
            default is '.'
812
        :param sciformat: 1. The sci notation has to be formatted as aE^+b
813
                          2. The sci notation has to be formatted as a·10^b
814
                          3. As 2, but with super html entity for exp
815
                          4. The sci notation has to be formatted as a·10^b
816
                          5. As 4, but with super html entity for exp
817
                          By default 1
818
        :param html: if true, returns an string with the special characters
819
            escaped: e.g: '<' and '>' (LDL and UDL for results like < 23.4).
820
        """
821
        result = self.getResult()
822
823
        # 1. The result is a detection limit, return '< LDL' or '> UDL'
824
        dl = self.getDetectionLimitOperand()
825
        if dl:
826
            try:
827
                res = float(result)  # required, check if floatable
828
                res = drop_trailing_zeros_decimal(res)
829
                fdm = formatDecimalMark(res, decimalmark)
830
                hdl = cgi.escape(dl) if html else dl
831
                return '%s %s' % (hdl, fdm)
832
            except (TypeError, ValueError):
833
                logger.warn(
834
                    "The result for the analysis %s is a detection limit, "
835
                    "but not floatable: %s" % (self.id, result))
836
                return formatDecimalMark(result, decimalmark=decimalmark)
837
838
        # 2. Print ResultText of matching ResultOptions
839
        choices = self.getResultOptions()
840
        if choices:
841
            # Create a dict for easy mapping of result options
842
            values_texts = dict(map(
843
                lambda c: (str(c["ResultValue"]), c["ResultText"]), choices
844
            ))
845
846
            # Result might contain a single result option
847
            match = values_texts.get(str(result))
848
            if match:
849
                return match
850
851
            # Result might be a string with multiple options e.g. "['2', '1']"
852
            try:
853
                raw_result = json.loads(result)
854
                texts = map(lambda r: values_texts.get(str(r)), raw_result)
0 ignored issues
show
introduced by
The variable values_texts does not seem to be defined in case choices on line 840 is False. Are you sure this can never be the case?
Loading history...
855
                texts = filter(None, texts)
856
                return "<br/>".join(texts)
857
            except (ValueError, TypeError):
858
                pass
859
860
        # 3. If the result is not floatable, return it without being formatted
861
        try:
862
            result = float(result)
863
        except (TypeError, ValueError):
864
            return formatDecimalMark(result, decimalmark=decimalmark)
865
866
        # 4. If the analysis specs has enabled hidemin or hidemax and the
867
        #    result is out of range, render result as '<min' or '>max'
868
        specs = specs if specs else self.getResultsRange()
869
        hidemin = specs.get('hidemin', '')
870
        hidemax = specs.get('hidemax', '')
871
        try:
872
            belowmin = hidemin and result < float(hidemin) or False
873
        except (TypeError, ValueError):
874
            belowmin = False
875
        try:
876
            abovemax = hidemax and result > float(hidemax) or False
877
        except (TypeError, ValueError):
878
            abovemax = False
879
880
        # 4.1. If result is below min and hidemin enabled, return '<min'
881
        if belowmin:
882
            fdm = formatDecimalMark('< %s' % hidemin, decimalmark)
883
            return fdm.replace('< ', '&lt; ', 1) if html else fdm
884
885
        # 4.2. If result is above max and hidemax enabled, return '>max'
886
        if abovemax:
887
            fdm = formatDecimalMark('> %s' % hidemax, decimalmark)
888
            return fdm.replace('> ', '&gt; ', 1) if html else fdm
889
890
        # Below Lower Detection Limit (LDL)?
891
        ldl = self.getLowerDetectionLimit()
892
        if result < ldl:
893
            # LDL must not be formatted according to precision, etc.
894
            # Drop trailing zeros from decimal
895
            ldl = drop_trailing_zeros_decimal(ldl)
896
            fdm = formatDecimalMark('< %s' % ldl, decimalmark)
897
            return fdm.replace('< ', '&lt; ', 1) if html else fdm
898
899
        # Above Upper Detection Limit (UDL)?
900
        udl = self.getUpperDetectionLimit()
901
        if result > udl:
902
            # UDL must not be formatted according to precision, etc.
903
            # Drop trailing zeros from decimal
904
            udl = drop_trailing_zeros_decimal(udl)
905
            fdm = formatDecimalMark('> %s' % udl, decimalmark)
906
            return fdm.replace('> ', '&gt; ', 1) if html else fdm
907
908
        # Render numerical values
909
        return format_numeric_result(self, self.getResult(),
910
                                     decimalmark=decimalmark,
911
                                     sciformat=sciformat)
912
913
    @security.public
914
    def getPrecision(self, result=None):
915
        """Returns the precision for the Analysis.
916
917
        - If ManualUncertainty is set, calculates the precision of the result
918
          in accordance with the manual uncertainty set.
919
920
        - If Calculate Precision from Uncertainty is set in Analysis Service,
921
          calculates the precision in accordance with the uncertainty infered
922
          from uncertainties ranges.
923
924
        - If neither Manual Uncertainty nor Calculate Precision from
925
          Uncertainty are set, returns the precision from the Analysis Service
926
927
        - If you have a number with zero uncertainty: If you roll a pair of
928
        dice and observe five spots, the number of spots is 5. This is a raw
929
        data point, with no uncertainty whatsoever. So just write down the
930
        number. Similarly, the number of centimeters per inch is 2.54,
931
        by definition, with no uncertainty whatsoever. Again: just write
932
        down the number.
933
934
        Further information at AbstractBaseAnalysis.getPrecision()
935
        """
936
        allow_manual = self.getAllowManualUncertainty()
937
        precision_unc = self.getPrecisionFromUncertainty()
938
        if allow_manual or precision_unc:
939
            uncertainty = self.getUncertainty(result)
940
            if uncertainty is None:
941
                return self.getField('Precision').get(self)
942
            if uncertainty == 0 and result is None:
943
                return self.getField('Precision').get(self)
944
            if uncertainty == 0:
945
                strres = str(result)
946
                numdecimals = strres[::-1].find('.')
947
                return numdecimals
948
            return get_significant_digits(uncertainty)
949
        return self.getField('Precision').get(self)
950
951
    @security.public
952
    def getAnalyst(self):
953
        """Returns the stored Analyst or the user who submitted the result
954
        """
955
        analyst = self.getField("Analyst").get(self) or self.getAssignedAnalyst()
956
        if not analyst:
957
            analyst = self.getSubmittedBy()
958
        return analyst or ""
959
960
    @security.public
961
    def getAssignedAnalyst(self):
962
        """Returns the Analyst assigned to the worksheet this
963
        analysis is assigned to
964
        """
965
        worksheet = self.getWorksheet()
966
        if not worksheet:
967
            return ""
968
        return worksheet.getAnalyst() or ""
969
970
    @security.public
971
    def getAnalystName(self):
972
        """Returns the name of the currently assigned analyst
973
        """
974
        analyst = self.getAnalyst()
975
        if not analyst:
976
            return ""
977
        user = api.get_user(analyst.strip())
978
        return user and user.getProperty("fullname") or analyst
979
980
    @security.public
981
    def getSubmittedBy(self):
982
        """
983
        Returns the identifier of the user who submitted the result if the
984
        state of the current analysis is "to_be_verified" or "verified"
985
        :return: the user_id of the user who did the last submission of result
986
        """
987
        return getTransitionActor(self, 'submit')
988
989
    @security.public
990
    def getDateSubmitted(self):
991
        """Returns the time the result was submitted.
992
        :return: a DateTime object.
993
        :rtype: DateTime
994
        """
995
        return getTransitionDate(self, 'submit', return_as_datetime=True)
996
997
    @security.public
998
    def getDateVerified(self):
999
        """Returns the time the analysis was verified. If the analysis hasn't
1000
        been yet verified, returns None
1001
        :return: the time the analysis was verified or None
1002
        :rtype: DateTime
1003
        """
1004
        return getTransitionDate(self, 'verify', return_as_datetime=True)
1005
1006
    @security.public
1007
    def getStartProcessDate(self):
1008
        """Returns the date time when the analysis is ready to be processed.
1009
        It returns the datetime when the object was created, but might be
1010
        different depending on the type of analysis (e.g. "Date Received" for
1011
        routine analyses): see overriden functions.
1012
        :return: Date time when the analysis is ready to be processed.
1013
        :rtype: DateTime
1014
        """
1015
        return self.created()
1016
1017
    @security.public
1018
    def getParentURL(self):
1019
        """This method is used to populate catalog values
1020
        This function returns the analysis' parent URL
1021
        """
1022
        parent = self.aq_parent
1023
        if parent:
1024
            return parent.absolute_url_path()
1025
1026
    @security.public
1027
    def getParentTitle(self):
1028
        """This method is used to populate catalog values
1029
        This function returns the analysis' parent Title
1030
        """
1031
        parent = self.aq_parent
1032
        if parent:
1033
            return parent.Title()
1034
1035
    @security.public
1036
    def getWorksheetUID(self):
1037
        """This method is used to populate catalog values
1038
        Returns WS UID if this analysis is assigned to a worksheet, or None.
1039
        """
1040
        worksheet = self.getWorksheet()
1041
        if worksheet:
1042
            return worksheet.UID()
1043
1044
    @security.public
1045
    def getWorksheet(self):
1046
        """Returns the Worksheet to which this analysis belongs to, or None
1047
        """
1048
        worksheet = self.getBackReferences('WorksheetAnalysis')
1049
        if not worksheet:
1050
            return None
1051
        if len(worksheet) > 1:
1052
            logger.error(
1053
                "Analysis %s is assigned to more than one worksheet."
1054
                % self.getId())
1055
        return worksheet[0]
1056
1057
    @security.public
1058
    def remove_duplicates(self, ws):
1059
        """When this analysis is unassigned from a worksheet, this function
1060
        is responsible for deleting DuplicateAnalysis objects from the ws.
1061
        """
1062
        for analysis in ws.objectValues():
1063
            if IDuplicateAnalysis.providedBy(analysis) \
1064
                    and analysis.getAnalysis().UID() == self.UID():
1065
                ws.removeAnalysis(analysis)
1066
1067
    def setInterimValue(self, keyword, value):
1068
        """Sets a value to an interim of this analysis
1069
        :param keyword: the keyword of the interim
1070
        :param value: the value for the interim
1071
        """
1072
        # Ensure result integrity regards to None, empty and 0 values
1073
        val = str('' if not value and value != 0 else value).strip()
1074
        interims = self.getInterimFields()
1075
        for interim in interims:
1076
            if interim['keyword'] == keyword:
1077
                interim['value'] = val
1078
                self.setInterimFields(interims)
1079
                return
1080
1081
        logger.warning("Interim '{}' for analysis '{}' not found"
1082
                       .format(keyword, self.getKeyword()))
1083
1084
    def getInterimValue(self, keyword):
1085
        """Returns the value of an interim of this analysis
1086
        """
1087
        interims = filter(lambda item: item["keyword"] == keyword,
1088
                          self.getInterimFields())
1089
        if not interims:
1090
            logger.warning("Interim '{}' for analysis '{}' not found"
1091
                           .format(keyword, self.getKeyword()))
1092
            return None
1093
        if len(interims) > 1:
1094
            logger.error("More than one interim '{}' found for '{}'"
1095
                         .format(keyword, self.getKeyword()))
1096
            return None
1097
        return interims[0].get('value', '')
1098
1099
    def isRetest(self):
1100
        """Returns whether this analysis is a retest or not
1101
        """
1102
        return self.getRetestOf() and True or False
1103
1104
    def getRetestOfUID(self):
1105
        """Returns the UID of the retracted analysis this is a retest of
1106
        """
1107
        retest_of = self.getRetestOf()
1108
        if retest_of:
1109
            return api.get_uid(retest_of)
1110
1111
    def getRetest(self):
1112
        """Returns the retest that comes from this analysis, if any
1113
        """
1114
        relationship = "{}RetestOf".format(self.portal_type)
1115
        back_refs = get_backreferences(self, relationship)
1116
        if not back_refs:
1117
            return None
1118
        if len(back_refs) > 1:
1119
            logger.warn("Analysis {} with multiple retests".format(self.id))
1120
        retest_uid = back_refs[0]
1121
        retest = api.get_object_by_uid(retest_uid, default=None)
1122
        if retest is None:
1123
            logger.error("Retest with UID {} not found".format(retest_uid))
1124
        return retest
1125