Passed
Pull Request — 2.x (#1857)
by Ramon
06:49 queued 02:32
created

AbstractAnalysis.getUpperDetectionLimit()   A

Complexity

Conditions 3

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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