Passed
Push — 2.x ( e2bf9c...e8dcb9 )
by Ramon
09:31
created

DynamicResultsRange.cmp_specs()   A

Complexity

Conditions 5

Size

Total Lines 23
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 15
dl 0
loc 23
rs 9.1832
c 0
b 0
f 0
cc 5
nop 3
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
        self.specification = None
51
        if self.analysisrequest:
52
            self.specification = self.analysisrequest.getSpecification()
53
        self.dynamicspec = None
54
        if self.specification:
55
            self.dynamicspec = self.specification.getDynamicAnalysisSpec()
56
57
    @property
58
    def keyword(self):
59
        """Analysis Keyword
60
        """
61
        return self.analysis.getKeyword()
62
63
    @property
64
    def range_keys(self):
65
        """The keys of the result range dict
66
        """
67
        if not self.specification:
68
            return DEFAULT_RANGE_KEYS
69
        # return the subfields of the specification
70
        return self.specification.getField("ResultsRange").subfields
71
72
    def convert(self, value):
73
        # convert referenced UIDs to the Title
74
        if api.is_uid(value):
75
            obj = api.get_object_by_uid(value)
76
            return api.get_title(obj)
77
        return value
78
79
    @memoize
80
    def get_match_data(self):
81
        """Returns a fieldname -> value mapping of context data
82
83
        The fieldnames are selected from the column names of the dynamic
84
        specifications file. E.g. the column "Method" of teh specifications
85
        file will lookup the value (title) of the Analysis and added to the
86
        mapping like this: `{"Method": "Method-1"}`.
87
88
        :returns: fieldname -> value mapping
89
        :rtype: dict
90
        """
91
        data = {}
92
93
        # Lookup the column names on the Analysis and the Analysis Request
94
        for column in self.dynamicspec.get_header():
95
            an_value = getattr(self.analysis, column, marker)
96
            ar_value = getattr(self.analysisrequest, column, marker)
97
            if an_value is not marker:
98
                data[column] = self.convert(an_value)
99
            elif ar_value is not marker:
100
                data[column] = self.convert(ar_value)
101
102
        return data
103
104
    def match(self, dynamic_range):
105
        """Returns whether the values of all fields declared in the dynamic
106
        specification for the current sample match with the values set in the
107
        given results range
108
        """
109
        data = self.get_match_data()
110
        if not data:
111
            return False
112
113
        for k, v in data.items():
114
            # a missing value in excel is considered a match
115
            value = dynamic_range.get(k)
116
            if not value and value != 0:
117
                continue
118
119
            # break if the values do not match
120
            if v != value:
121
                return False
122
123
        # all key values matched
124
        return True
125
126
    def cmp_specs(self, a, b):
127
        """Compares two specification records
128
        """
129
        def is_empty(value):
130
            return not value and value != 0
131
132
        # specs with less empty values have priority
133
        keys = set(a.keys() + b.keys())
134
        empties_a = len(filter(lambda key: is_empty(a.get(key)), keys))
135
        empties_b = len(filter(lambda key: is_empty(b.get(key)), keys))
136
        if empties_a != empties_b:
137
            return cmp(empties_a, empties_b)
138
139
        # spec with highest min value has priority
140
        min_a = api.to_float(a.get("min"), 0)
141
        min_b = api.to_float(b.get("min"), 0)
142
        if min_a != min_b:
143
            return cmp(min_b, min_a)
144
145
        # spec with lowest max value has priority
146
        max_a = api.to_float(a.get("max"), 0)
147
        max_b = api.to_float(b.get("max"), 0)
148
        return cmp(max_a, max_b)
149
150
    def get_results_range(self):
151
        """Return the dynamic results range
152
153
        The returning dicitionary should containe at least the `min` and `max`
154
        values to override the ResultsRangeDict data.
155
156
        :returns: An `IResultsRangeDict` compatible dict
157
        :rtype: dict
158
        """
159
        if self.dynamicspec is None:
160
            return {}
161
162
        # A matching Analysis Keyword is mandatory for any further matches
163
        keyword = self.analysis.getKeyword()
164
        by_keyword = self.dynamicspec.get_by_keyword()
165
166
        # Get all specs (rows) from the Excel with the same Keyword
167
        specs = by_keyword.get(keyword)
168
        if not specs:
169
            return {}
170
171
        # Filter those with a match
172
        specs = filter(self.match, specs)
173
        if not specs:
174
            return {}
175
176
        # Sort them and pick the first match, that is less generic
177
        spec = sorted(specs, cmp=self.cmp_specs)[0]
178
179
        # at this point we have a match, update the results range dict
180
        rr = {}
181
        for key in self.range_keys:
182
            value = spec.get(key, marker)
183
            # skip if the range key is not set in the Excel
184
            if value is marker:
185
                continue
186
            # skip if the value is not floatable
187
            if not api.is_floatable(value):
188
                continue
189
            # set the range value
190
            rr[key] = value
191
        # return the updated result range
192
        return rr
193
194
    def __call__(self):
195
        return self.get_results_range()
196