Passed
Push — 2.x ( 1a21e9...2cfd51 )
by Ramon
07:19
created

DynamicResultsRange.range_keys()   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-2024 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
from bika.lims import api
22
from bika.lims.interfaces import IDynamicResultsRange
23
from plone.memoize.instance import memoize
24
from senaite.core.p3compat import cmp
25
from zope.interface import implementer
26
27
marker = object()
28
29
DEFAULT_RANGE_KEYS = [
30
    "min",
31
    "warn_min",
32
    "min_operator",
33
    "minpanic",
34
    "max",
35
    "warn_max",
36
    "max",
37
    "maxpanic",
38
    "error",
39
]
40
41
42
@implementer(IDynamicResultsRange)
43
class DynamicResultsRange(object):
44
    """Default Dynamic Results Range Adapter
45
    """
46
47
    def __init__(self, analysis):
48
        self.analysis = analysis
49
        self.analysisrequest = analysis.getRequest()
50
51
    @property
52
    def specification(self):
53
        spec = None
54
        try:
55
            spec = self.analysisrequest.getSpecification()
56
        except AttributeError:
57
            # specification is only possible for AnalysisRequest's
58
            pass
59
        return spec
60
61
    @property
62
    def dynamicspec(self):
63
        dspec = None
64
        if self.specification:
65
            dspec = self.specification.getDynamicAnalysisSpec()
66
        return dspec
67
68
    @property
69
    def keyword(self):
70
        """Analysis Keyword
71
        """
72
        return self.analysis.getKeyword()
73
74
    @property
75
    def range_keys(self):
76
        """The keys of the result range dict
77
        """
78
        if not self.specification:
79
            return DEFAULT_RANGE_KEYS
80
        # return the subfields of the specification
81
        return self.specification.getField("ResultsRange").subfields
82
83
    def convert(self, value):
84
        # convert referenced UIDs to the Title
85
        if api.is_uid(value):
86
            obj = api.get_object_by_uid(value)
87
            return api.get_title(obj)
88
        return value
89
90
    @memoize
91
    def get_match_data(self):
92
        """Returns a fieldname -> value mapping of context data
93
94
        The fieldnames are selected from the column names of the dynamic
95
        specifications file. E.g. the column "Method" of teh specifications
96
        file will lookup the value (title) of the Analysis and added to the
97
        mapping like this: `{"Method": "Method-1"}`.
98
99
        :returns: fieldname -> value mapping
100
        :rtype: dict
101
        """
102
        data = {}
103
104
        # Lookup the column names on the Analysis and the Analysis Request
105
        for column in self.dynamicspec.get_header():
106
            an_value = getattr(self.analysis, column, marker)
107
            ar_value = getattr(self.analysisrequest, column, marker)
108
            if an_value is not marker:
109
                data[column] = self.convert(an_value)
110
            elif ar_value is not marker:
111
                data[column] = self.convert(ar_value)
112
113
        return data
114
115
    def match(self, dynamic_range):
116
        """Returns whether the values of all fields declared in the dynamic
117
        specification for the current sample match with the values set in the
118
        given results range
119
        """
120
        data = self.get_match_data()
121
        if not data:
122
            return False
123
124
        for k, v in data.items():
125
            # a missing value in excel is considered a match
126
            value = dynamic_range.get(k)
127
            if not value and value != 0:
128
                continue
129
130
            # break if the values do not match
131
            if v != value:
132
                return False
133
134
        # all key values matched
135
        return True
136
137
    def cmp_specs(self, a, b):
138
        """Compares two specification records
139
        """
140
        def is_empty(value):
141
            return not value and value != 0
142
143
        # specs with less empty values have priority
144
        keys = set(a.keys() + b.keys())
145
        empties_a = len(filter(lambda key: is_empty(a.get(key)), keys))
146
        empties_b = len(filter(lambda key: is_empty(b.get(key)), keys))
147
        if empties_a != empties_b:
148
            return cmp(empties_a, empties_b)
149
150
        # spec with highest min value has priority
151
        min_a = api.to_float(a.get("min"), 0)
152
        min_b = api.to_float(b.get("min"), 0)
153
        if min_a != min_b:
154
            return cmp(min_b, min_a)
155
156
        # spec with lowest max value has priority
157
        max_a = api.to_float(a.get("max"), 0)
158
        max_b = api.to_float(b.get("max"), 0)
159
        return cmp(max_a, max_b)
160
161
    def get_results_range(self):
162
        """Return the dynamic results range
163
164
        The returning dicitionary should containe at least the `min` and `max`
165
        values to override the ResultsRangeDict data.
166
167
        :returns: An `IResultsRangeDict` compatible dict
168
        :rtype: dict
169
        """
170
        if self.dynamicspec is None:
171
            return {}
172
173
        # A matching Analysis Keyword is mandatory for any further matches
174
        keyword = self.analysis.getKeyword()
175
        by_keyword = self.dynamicspec.get_by_keyword()
176
177
        # Get all specs (rows) from the Excel with the same Keyword
178
        specs = by_keyword.get(keyword)
179
        if not specs:
180
            return {}
181
182
        # Filter those with a match
183
        specs = filter(self.match, specs)
184
        if not specs:
185
            return {}
186
187
        # Sort them and pick the first match, that is less generic
188
        spec = sorted(specs, cmp=self.cmp_specs)[0]
189
190
        # at this point we have a match, update the results range dict
191
        rr = {}
192
        for key in self.range_keys:
193
            value = spec.get(key, marker)
194
            # skip if the range key is not set in the Excel
195
            if value is marker:
196
                continue
197
            # skip if the value is not floatable
198
            if not api.is_floatable(value):
199
                continue
200
            # set the range value
201
            rr[key] = value
202
        # return the updated result range
203
        return rr
204
205
    def __call__(self):
206
        return self.get_results_range()
207