Passed
Push — master ( d8e2ec...90ae0b )
by Jordi
10:07 queued 04:19
created

AbstractAnalysis.getServiceUID()   A

Complexity

Conditions 2

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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