Passed
Push — 2.x ( 6d4760...bb5fa7 )
by Ramon
09:42 queued 04:48
created

AbstractAnalysis.getRawAllowedMethods()   A

Complexity

Conditions 2

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 2
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 getPrice(self):
634
        """The function obtains the analysis' price without VAT and without
635
        member discount
636
        :return: the price (without VAT or Member Discount) in decimal format
637
        """
638
        analysis_request = self.aq_parent
639
        client = analysis_request.aq_parent
640
        if client.getBulkDiscount():
641
            price = self.getBulkPrice()
642
        else:
643
            price = self.getField('Price').get(self)
644
        return price
645
646
    @security.public
647
    def getVATAmount(self):
648
        """Compute the VAT amount without member discount.
649
        :return: the result as a float
650
        """
651
        vat = self.getVAT()
652
        price = self.getPrice()
653
        return Decimal(price) * Decimal(vat) / 100
654
655
    @security.public
656
    def getTotalPrice(self):
657
        """Obtain the total price without client's member discount. The function
658
        keeps in mind the client's bulk discount.
659
        :return: the result as a float
660
        """
661
        return Decimal(self.getPrice()) + Decimal(self.getVATAmount())
662
663
    @security.public
664
    def getDuration(self):
665
        """Returns the time in minutes taken for this analysis.
666
        If the analysis is not yet 'ready to process', returns 0
667
        If the analysis is still in progress (not yet verified),
668
            duration = date_verified - date_start_process
669
        Otherwise:
670
            duration = current_datetime - date_start_process
671
        :return: time in minutes taken for this analysis
672
        :rtype: int
673
        """
674
        starttime = self.getStartProcessDate()
675
        if not starttime:
676
            # The analysis is not yet ready to be processed
677
            return 0
678
        endtime = self.getDateVerified() or DateTime()
679
680
        # Duration in minutes
681
        duration = (endtime - starttime) * 24 * 60
682
        return duration
683
684
    @security.public
685
    def getEarliness(self):
686
        """The remaining time in minutes for this analysis to be completed.
687
        Returns zero if the analysis is neither 'ready to process' nor a
688
        turnaround time is set.
689
            earliness = duration - max_turnaround_time
690
        The analysis is late if the earliness is negative
691
        :return: the remaining time in minutes before the analysis reaches TAT
692
        :rtype: int
693
        """
694
        maxtime = self.getMaxTimeAllowed()
695
        if not maxtime:
696
            # No Turnaround time is set for this analysis
697
            return 0
698
        return api.to_minutes(**maxtime) - self.getDuration()
699
700
    @security.public
701
    def isLateAnalysis(self):
702
        """Returns true if the analysis is late in accordance with the maximum
703
        turnaround time. If no maximum turnaround time is set for this analysis
704
        or it is not yet ready to be processed, or there is still time
705
        remaining (earliness), returns False.
706
        :return: true if the analysis is late
707
        :rtype: bool
708
        """
709
        return self.getEarliness() < 0
710
711
    @security.public
712
    def getLateness(self):
713
        """The time in minutes that exceeds the maximum turnaround set for this
714
        analysis. If the analysis has no turnaround time set or is not ready
715
        for process yet, returns 0. The analysis is not late if the lateness is
716
        negative
717
        :return: the time in minutes that exceeds the maximum turnaround time
718
        :rtype: int
719
        """
720
        return -self.getEarliness()
721
722
    @security.public
723
    def isInstrumentAllowed(self, instrument):
724
        """Checks if the specified instrument can be set for this analysis,
725
726
        :param instrument: string,Instrument
727
        :return: True if the assignment of the passed in instrument is allowed
728
        :rtype: bool
729
        """
730
        uid = api.get_uid(instrument)
731
        return uid in self.getRawAllowedInstruments()
732
733
    @security.public
734
    def isMethodAllowed(self, method):
735
        """Checks if the analysis can follow the method specified
736
737
        :param method: string,Method
738
        :return: True if the analysis can follow the method specified
739
        :rtype: bool
740
        """
741
        uid = api.get_uid(method)
742
        return uid in self.getRawAllowedMethods()
743
744
    @security.public
745
    def getAllowedMethods(self):
746
        """Returns the allowed methods for this analysis, either if the method
747
        was assigned directly (by using "Allows manual entry of results") or
748
        indirectly via Instrument ("Allows instrument entry of results") in
749
        Analysis Service Edit View.
750
        :return: A list with the methods allowed for this analysis
751
        :rtype: list of Methods
752
        """
753
        service = self.getAnalysisService()
754
        if not service:
755
            return []
756
        # get the available methods of the service
757
        return service.getMethods()
758
759
    @security.public
760
    def getRawAllowedMethods(self):
761
        """Returns the UIDs of the allowed methods for this analysis
762
        """
763
        service = self.getAnalysisService()
764
        if not service:
765
            return []
766
        return service.getRawMethods()
767
768
    @security.public
769
    def getAllowedInstruments(self):
770
        """Returns the allowed instruments from the service
771
772
        :return: A list of instruments allowed for this Analysis
773
        :rtype: list of instruments
774
        """
775
        service = self.getAnalysisService()
776
        if not service:
777
            return []
778
        return service.getInstruments()
779
780
    @security.public
781
    def getRawAllowedInstruments(self):
782
        """Returns the UIDS of the allowed instruments from the service
783
        """
784
        service = self.getAnalysisService()
785
        if not service:
786
            return []
787
        return service.getRawInstruments()
788
789
    @security.public
790
    def getExponentialFormatPrecision(self, result=None):
791
        """ Returns the precision for the Analysis Service and result
792
        provided. Results with a precision value above this exponential
793
        format precision should be formatted as scientific notation.
794
795
        If the Calculate Precision according to Uncertainty is not set,
796
        the method will return the exponential precision value set in the
797
        Schema. Otherwise, will calculate the precision value according to
798
        the Uncertainty and the result.
799
800
        If Calculate Precision from the Uncertainty is set but no result
801
        provided neither uncertainty values are set, returns the fixed
802
        exponential precision.
803
804
        Will return positive values if the result is below 0 and will return
805
        0 or positive values if the result is above 0.
806
807
        Given an analysis service with fixed exponential format
808
        precision of 4:
809
        Result      Uncertainty     Returns
810
        5.234           0.22           0
811
        13.5            1.34           1
812
        0.0077          0.008         -3
813
        32092           0.81           4
814
        456021          423            5
815
816
        For further details, visit https://jira.bikalabs.com/browse/LIMS-1334
817
818
        :param result: if provided and "Calculate Precision according to the
819
        Uncertainty" is set, the result will be used to retrieve the
820
        uncertainty from which the precision must be calculated. Otherwise,
821
        the fixed-precision will be used.
822
        :returns: the precision
823
        """
824
        if not result or self.getPrecisionFromUncertainty() is False:
825
            return self._getExponentialFormatPrecision()
826
        else:
827
            uncertainty = self.getUncertainty(result)
828
            if uncertainty is None:
829
                return self._getExponentialFormatPrecision()
830
831
            try:
832
                float(result)
833
            except ValueError:
834
                # if analysis result is not a number, then we assume in range
835
                return self._getExponentialFormatPrecision()
836
837
            return get_significant_digits(uncertainty)
838
839
    def _getExponentialFormatPrecision(self):
840
        field = self.getField('ExponentialFormatPrecision')
841
        value = field.get(self)
842
        if value is None:
843
            # https://github.com/bikalims/bika.lims/issues/2004
844
            # We require the field, because None values make no sense at all.
845
            value = self.Schema().getField(
846
                'ExponentialFormatPrecision').getDefault(self)
847
        return value
848
849
    @security.public
850
    def getFormattedResult(self, specs=None, decimalmark='.', sciformat=1,
851
                           html=True):
852
        """Formatted result:
853
        1. If the result is a detection limit, returns '< LDL' or '> UDL'
854
        2. Print ResultText of matching ResultOptions
855
        3. If the result is not floatable, return it without being formatted
856
        4. If the analysis specs has hidemin or hidemax enabled and the
857
           result is out of range, render result as '<min' or '>max'
858
        5. If the result is below Lower Detection Limit, show '<LDL'
859
        6. If the result is above Upper Detecion Limit, show '>UDL'
860
        7. Otherwise, render numerical value
861
        :param specs: Optional result specifications, a dictionary as follows:
862
            {'min': <min_val>,
863
             'max': <max_val>,
864
             'error': <error>,
865
             'hidemin': <hidemin_val>,
866
             'hidemax': <hidemax_val>}
867
        :param decimalmark: The string to be used as a decimal separator.
868
            default is '.'
869
        :param sciformat: 1. The sci notation has to be formatted as aE^+b
870
                          2. The sci notation has to be formatted as a·10^b
871
                          3. As 2, but with super html entity for exp
872
                          4. The sci notation has to be formatted as a·10^b
873
                          5. As 4, but with super html entity for exp
874
                          By default 1
875
        :param html: if true, returns an string with the special characters
876
            escaped: e.g: '<' and '>' (LDL and UDL for results like < 23.4).
877
        """
878
        result = self.getResult()
879
880
        # 1. The result is a detection limit, return '< LDL' or '> UDL'
881
        dl = self.getDetectionLimitOperand()
882
        if dl:
883
            try:
884
                res = api.float_to_string(float(result))
885
                fdm = formatDecimalMark(res, decimalmark)
886
                hdl = cgi.escape(dl) if html else dl
887
                return '%s %s' % (hdl, fdm)
888
            except (TypeError, ValueError):
889
                logger.warn(
890
                    "The result for the analysis %s is a detection limit, "
891
                    "but not floatable: %s" % (self.id, result))
892
                return formatDecimalMark(result, decimalmark=decimalmark)
893
894
        # 2. Print ResultText of matching ResultOptions
895
        choices = self.getResultOptions()
896
        if choices:
897
            # Create a dict for easy mapping of result options
898
            values_texts = dict(map(
899
                lambda c: (str(c["ResultValue"]), c["ResultText"]), choices
900
            ))
901
902
            # Result might contain a single result option
903
            match = values_texts.get(str(result))
904
            if match:
905
                return match
906
907
            # Result might be a string with multiple options e.g. "['2', '1']"
908
            try:
909
                raw_result = json.loads(result)
910
                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 896 is False. Are you sure this can never be the case?
Loading history...
911
                texts = filter(None, texts)
912
                return "<br/>".join(texts)
913
            except (ValueError, TypeError):
914
                pass
915
916
        # 3. If the result is not floatable, return it without being formatted
917
        try:
918
            result = float(result)
919
        except (TypeError, ValueError):
920
            return formatDecimalMark(result, decimalmark=decimalmark)
921
922
        # 4. If the analysis specs has enabled hidemin or hidemax and the
923
        #    result is out of range, render result as '<min' or '>max'
924
        specs = specs if specs else self.getResultsRange()
925
        hidemin = specs.get('hidemin', '')
926
        hidemax = specs.get('hidemax', '')
927
        try:
928
            belowmin = hidemin and result < float(hidemin) or False
929
        except (TypeError, ValueError):
930
            belowmin = False
931
        try:
932
            abovemax = hidemax and result > float(hidemax) or False
933
        except (TypeError, ValueError):
934
            abovemax = False
935
936
        # 4.1. If result is below min and hidemin enabled, return '<min'
937
        if belowmin:
938
            fdm = formatDecimalMark('< %s' % hidemin, decimalmark)
939
            return fdm.replace('< ', '&lt; ', 1) if html else fdm
940
941
        # 4.2. If result is above max and hidemax enabled, return '>max'
942
        if abovemax:
943
            fdm = formatDecimalMark('> %s' % hidemax, decimalmark)
944
            return fdm.replace('> ', '&gt; ', 1) if html else fdm
945
946
        # Below Lower Detection Limit (LDL)?
947
        ldl = self.getLowerDetectionLimit()
948
        ldl = api.to_float(ldl, 0.0)
949
        if result < ldl:
950
            # LDL must not be formatted according to precision, etc.
951
            ldl = api.float_to_string(ldl)
952
            fdm = formatDecimalMark('< %s' % ldl, decimalmark)
953
            return fdm.replace('< ', '&lt; ', 1) if html else fdm
954
955
        # Above Upper Detection Limit (UDL)?
956
        udl = self.getUpperDetectionLimit()
957
        udl = api.to_float(udl, 0.0)
958
        if result > udl:
959
            # UDL must not be formatted according to precision, etc.
960
            udl = api.float_to_string(udl)
961
            fdm = formatDecimalMark('> %s' % udl, decimalmark)
962
            return fdm.replace('> ', '&gt; ', 1) if html else fdm
963
964
        # Render numerical values
965
        return format_numeric_result(self, self.getResult(),
966
                                     decimalmark=decimalmark,
967
                                     sciformat=sciformat)
968
969
    @security.public
970
    def getPrecision(self, result=None):
971
        """Returns the precision for the Analysis.
972
973
        - If ManualUncertainty is set, calculates the precision of the result
974
          in accordance with the manual uncertainty set.
975
976
        - If Calculate Precision from Uncertainty is set in Analysis Service,
977
          calculates the precision in accordance with the uncertainty infered
978
          from uncertainties ranges.
979
980
        - If neither Manual Uncertainty nor Calculate Precision from
981
          Uncertainty are set, returns the precision from the Analysis Service
982
983
        - If you have a number with zero uncertainty: If you roll a pair of
984
        dice and observe five spots, the number of spots is 5. This is a raw
985
        data point, with no uncertainty whatsoever. So just write down the
986
        number. Similarly, the number of centimeters per inch is 2.54,
987
        by definition, with no uncertainty whatsoever. Again: just write
988
        down the number.
989
990
        Further information at AbstractBaseAnalysis.getPrecision()
991
        """
992
        allow_manual = self.getAllowManualUncertainty()
993
        precision_unc = self.getPrecisionFromUncertainty()
994
        if allow_manual or precision_unc:
995
            uncertainty = self.getUncertainty(result)
996
            if uncertainty is None:
997
                return self.getField("Precision").get(self)
998
            if api.to_float(uncertainty) == 0 and result is None:
999
                return self.getField("Precision").get(self)
1000
            if api.to_float(uncertainty) == 0:
1001
                strres = str(result)
1002
                numdecimals = strres[::-1].find('.')
1003
                return numdecimals
1004
            return get_significant_digits(uncertainty)
1005
        return self.getField('Precision').get(self)
1006
1007
    @security.public
1008
    def getAnalyst(self):
1009
        """Returns the stored Analyst or the user who submitted the result
1010
        """
1011
        analyst = self.getField("Analyst").get(self) or self.getAssignedAnalyst()
1012
        if not analyst:
1013
            analyst = self.getSubmittedBy()
1014
        return analyst or ""
1015
1016
    @security.public
1017
    def getAssignedAnalyst(self):
1018
        """Returns the Analyst assigned to the worksheet this
1019
        analysis is assigned to
1020
        """
1021
        worksheet = self.getWorksheet()
1022
        if not worksheet:
1023
            return ""
1024
        return worksheet.getAnalyst() or ""
1025
1026
    @security.public
1027
    def getSubmittedBy(self):
1028
        """
1029
        Returns the identifier of the user who submitted the result if the
1030
        state of the current analysis is "to_be_verified" or "verified"
1031
        :return: the user_id of the user who did the last submission of result
1032
        """
1033
        return getTransitionActor(self, 'submit')
1034
1035
    @security.public
1036
    def getDateSubmitted(self):
1037
        """Returns the time the result was submitted.
1038
        :return: a DateTime object.
1039
        :rtype: DateTime
1040
        """
1041
        return getTransitionDate(self, 'submit', return_as_datetime=True)
1042
1043
    @security.public
1044
    def getDateVerified(self):
1045
        """Returns the time the analysis was verified. If the analysis hasn't
1046
        been yet verified, returns None
1047
        :return: the time the analysis was verified or None
1048
        :rtype: DateTime
1049
        """
1050
        return getTransitionDate(self, 'verify', return_as_datetime=True)
1051
1052
    @security.public
1053
    def getStartProcessDate(self):
1054
        """Returns the date time when the analysis is ready to be processed.
1055
        It returns the datetime when the object was created, but might be
1056
        different depending on the type of analysis (e.g. "Date Received" for
1057
        routine analyses): see overriden functions.
1058
        :return: Date time when the analysis is ready to be processed.
1059
        :rtype: DateTime
1060
        """
1061
        return self.created()
1062
1063
    @security.public
1064
    def getParentURL(self):
1065
        """This method is used to populate catalog values
1066
        This function returns the analysis' parent URL
1067
        """
1068
        parent = self.aq_parent
1069
        if parent:
1070
            return parent.absolute_url_path()
1071
1072
    @security.public
1073
    def getWorksheetUID(self):
1074
        """This method is used to populate catalog values
1075
        Returns WS UID if this analysis is assigned to a worksheet, or None.
1076
        """
1077
        uids = get_backreferences(self, relationship="WorksheetAnalysis")
1078
        if not uids:
1079
            return None
1080
1081
        if len(uids) > 1:
1082
            path = api.get_path(self)
1083
            logger.error("More than one worksheet: {}".format(path))
1084
            return None
1085
1086
        return uids[0]
1087
1088
    @security.public
1089
    def getWorksheet(self):
1090
        """Returns the Worksheet to which this analysis belongs to, or None
1091
        """
1092
        worksheet_uid = self.getWorksheetUID()
1093
        return api.get_object_by_uid(worksheet_uid, None)
1094
1095
    @security.public
1096
    def remove_duplicates(self, ws):
1097
        """When this analysis is unassigned from a worksheet, this function
1098
        is responsible for deleting DuplicateAnalysis objects from the ws.
1099
        """
1100
        for analysis in ws.objectValues():
1101
            if IDuplicateAnalysis.providedBy(analysis) \
1102
                    and analysis.getAnalysis().UID() == self.UID():
1103
                ws.removeAnalysis(analysis)
1104
1105
    def setInterimValue(self, keyword, value):
1106
        """Sets a value to an interim of this analysis
1107
        :param keyword: the keyword of the interim
1108
        :param value: the value for the interim
1109
        """
1110
        # Ensure value format integrity
1111
        if value is None:
1112
            value = ""
1113
        elif isinstance(value, string_types):
1114
            value = value.strip()
1115
        elif isinstance(value, (list, tuple, set, dict)):
1116
            value = json.dumps(value)
1117
1118
        # Ensure result integrity regards to None, empty and 0 values
1119
        interims = copy.deepcopy(self.getInterimFields())
1120
        for interim in interims:
1121
            if interim.get("keyword") == keyword:
1122
                interim["value"] = str(value)
1123
        self.setInterimFields(interims)
1124
1125
    def getInterimValue(self, keyword):
1126
        """Returns the value of an interim of this analysis
1127
        """
1128
        interims = filter(lambda item: item["keyword"] == keyword,
1129
                          self.getInterimFields())
1130
        if not interims:
1131
            logger.warning("Interim '{}' for analysis '{}' not found"
1132
                           .format(keyword, self.getKeyword()))
1133
            return None
1134
        if len(interims) > 1:
1135
            logger.error("More than one interim '{}' found for '{}'"
1136
                         .format(keyword, self.getKeyword()))
1137
            return None
1138
        return interims[0].get('value', '')
1139
1140
    def isRetest(self):
1141
        """Returns whether this analysis is a retest or not
1142
        """
1143
        return self.getRetestOf() and True or False
1144
1145
    def getRetestOfUID(self):
1146
        """Returns the UID of the retracted analysis this is a retest of
1147
        """
1148
        retest_of = self.getRetestOf()
1149
        if retest_of:
1150
            return api.get_uid(retest_of)
1151
1152
    def getRetest(self):
1153
        """Returns the retest that comes from this analysis, if any
1154
        """
1155
        relationship = "{}RetestOf".format(self.portal_type)
1156
        back_refs = get_backreferences(self, relationship)
1157
        if not back_refs:
1158
            return None
1159
        if len(back_refs) > 1:
1160
            logger.warn("Analysis {} with multiple retests".format(self.id))
1161
        retest_uid = back_refs[0]
1162
        retest = api.get_object_by_uid(retest_uid, default=None)
1163
        if retest is None:
1164
            logger.error("Retest with UID {} not found".format(retest_uid))
1165
        return retest
1166