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