Passed
Push — master ( 743380...0e02ba )
by Ramon
05:40
created

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