Passed
Push — master ( 928a23...85debf )
by Jordi
07:10 queued 01:09
created

AbstractAnalysis.getNumberOfVerifications()   A

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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