Passed
Push — 2.x ( bf6542...bdcf9c )
by Ramon
06:11
created

AbstractAnalysis.isLateAnalysis()   A

Complexity

Conditions 1

Size

Total Lines 10
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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