Passed
Push — master ( 2370a7...d5eb0b )
by Ramon
04:38
created

AbstractAnalysis.isUserAllowedToVerify()   A

Complexity

Conditions 1

Size

Total Lines 14
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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