Passed
Push — 2.x ( 269f1b...27353a )
by Ramon
05:03
created

AbstractAnalysis.getParentURL()   A

Complexity

Conditions 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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