Passed
Push — master ( b9e514...257903 )
by Jordi
11:51 queued 05:38
created

ARAnalysesField.get_from_instance()   A

Complexity

Conditions 3

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 3
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-2019 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import itertools
22
23
from AccessControl import ClassSecurityInfo
24
from AccessControl import Unauthorized
25
from Products.Archetypes.Registry import registerField
26
from Products.Archetypes.public import Field
27
from Products.Archetypes.public import ObjectField
28
from zope.interface import implements
29
30
from bika.lims import api
31
from bika.lims import logger
32
from bika.lims.api.security import check_permission
33
from bika.lims.catalog import CATALOG_ANALYSIS_LISTING
34
from bika.lims.interfaces import IARAnalysesField
35
from bika.lims.interfaces import IAnalysis
36
from bika.lims.interfaces import IAnalysisService
37
from bika.lims.interfaces import ISubmitted
38
from bika.lims.permissions import AddAnalysis
39
from bika.lims.utils.analysis import create_analysis
40
41
"""Field to manage Analyses on ARs
42
43
Please see the assigned doctest at tests/doctests/ARAnalysesField.rst
44
45
Run this test from the buildout directory:
46
47
    bin/test test_textual_doctests -t ARAnalysesField
48
"""
49
50
51
class ARAnalysesField(ObjectField):
52
    """A field that stores Analyses instances
53
    """
54
    implements(IARAnalysesField)
55
56
    security = ClassSecurityInfo()
57
    _properties = Field._properties.copy()
58
    _properties.update({
59
        "type": "analyses",
60
        "default": None,
61
    })
62
63
    security.declarePrivate('get')
64
65
    def get(self, instance, **kwargs):
66
        """Returns a list of Analyses assigned to this AR
67
68
        Return a list of catalog brains unless `full_objects=True` is passed.
69
        Other keyword arguments are passed to bika_analysis_catalog
70
71
        :param instance: Analysis Request object
72
        :param kwargs: Keyword arguments to inject in the search query
73
        :returns: A list of Analysis Objects/Catalog Brains
74
        """
75
        # Do we need to return objects or brains
76
        full_objects = kwargs.get("full_objects", False)
77
78
        # Bail out parameters from kwargs that don't match with indexes
79
        catalog = api.get_tool(CATALOG_ANALYSIS_LISTING)
80
        indexes = catalog.indexes()
81
        query = dict([(k, v) for k, v in kwargs.items() if k in indexes])
82
83
        # Do the search against the catalog
84
        query["portal_type"] = "Analysis"
85
        query["getAncestorsUIDs"] = api.get_uid(instance)
86
        brains = catalog(query)
87
        if full_objects:
88
            return map(api.get_object, brains)
89
        return brains
90
91
    security.declarePrivate('set')
92
93
    def set(self, instance, items, prices=None, specs=None, hidden=None, **kw):
94
        """Set/Assign Analyses to this AR
95
96
        :param items: List of Analysis objects/brains, AnalysisService
97
                      objects/brains and/or Analysis Service uids
98
        :type items: list
99
        :param prices: Mapping of AnalysisService UID -> price
100
        :type prices: dict
101
        :param specs: List of AnalysisService UID -> Result Range mappings
102
        :type specs: list
103
        :param hidden: List of AnalysisService UID -> Hidden mappings
104
        :type hidden: list
105
        :returns: list of new assigned Analyses
106
        """
107
        if items is None:
108
            items = []
109
110
        # Bail out if the items is not a list type
111
        if not isinstance(items, (list, tuple)):
112
            raise TypeError(
113
                "Items parameter must be a tuple or list, got '{}'".format(
114
                    type(items)))
115
116
        # Bail out if the AR is inactive
117
        if not api.is_active(instance):
118
            raise Unauthorized("Inactive ARs can not be modified"
119
                               .format(AddAnalysis))
120
121
        # Bail out if the user has not the right permission
122
        if not check_permission(AddAnalysis, instance):
123
            raise Unauthorized("You do not have the '{}' permission"
124
                               .format(AddAnalysis))
125
126
        # Convert the items to a valid list of AnalysisServices
127
        services = filter(None, map(self._to_service, items))
128
129
        # Calculate dependencies
130
        # FIXME Infinite recursion error possible here, if the formula includes
131
        #       the Keyword of the Service that includes the Calculation
132
        dependencies = map(lambda s: s.getServiceDependencies(), services)
133
        dependencies = list(itertools.chain.from_iterable(dependencies))
134
135
        # Merge dependencies and services
136
        services = set(services + dependencies)
137
138
        # Modify existing AR specs with new form values of selected analyses.
139
        self._update_specs(instance, specs)
140
141
        # Create a mapping of Service UID -> Hidden status
142
        if hidden is None:
143
            hidden = []
144
        hidden = dict(map(lambda d: (d.get("uid"), d.get("hidden")), hidden))
145
146
        # Ensure we have a prices dictionary
147
        if prices is None:
148
            prices = dict()
149
150
        # Add analyses
151
        new_analyses = map(lambda service:
152
                           self.add_analysis(instance, service, prices, hidden),
153
                           services)
154
        new_analyses = filter(None, new_analyses)
155
156
        # Remove analyses
157
        # Since Manage Analyses view displays the analyses from partitions, we
158
        # also need to take them into consideration here. Analyses from
159
        # ancestors can be omitted.
160
        analyses = instance.objectValues("Analysis")
161
        analyses.extend(self.get_analyses_from_descendants(instance))
162
163
        # Service UIDs
164
        service_uids = map(api.get_uid, services)
165
166
        # Assigned Attachments
167
        assigned_attachments = []
168
169
        for analysis in analyses:
170
            service_uid = analysis.getServiceUID()
171
172
            # Skip if the Service is selected
173
            if service_uid in service_uids:
174
                continue
175
176
            # Skip non-open Analyses
177
            if ISubmitted.providedBy(analysis):
178
                continue
179
180
            # Remember assigned attachments
181
            # https://github.com/senaite/senaite.core/issues/1025
182
            assigned_attachments.extend(analysis.getAttachment())
183
            analysis.setAttachment([])
184
185
            # If it is assigned to a worksheet, unassign it before deletion.
186
            worksheet = analysis.getWorksheet()
187
            if worksheet:
188
                worksheet.removeAnalysis(analysis)
189
190
            # Remove the analysis
191
            # Note the analysis might belong to a partition
192
            analysis.aq_parent.manage_delObjects(ids=[api.get_id(analysis)])
193
194
        # Remove orphaned attachments
195
        for attachment in assigned_attachments:
196
            # only delete attachments which are no further linked
197
            if not attachment.getLinkedAnalyses():
198
                logger.info(
199
                    "Deleting attachment: {}".format(attachment.getId()))
200
                attachment_id = api.get_id(attachment)
201
                api.get_parent(attachment).manage_delObjects(attachment_id)
202
203
        return new_analyses
204
205
    def add_analysis(self, instance, service, prices, hidden):
206
        service_uid = api.get_uid(service)
207
        new_analysis = False
208
209
        # Gets the analysis or creates the analysis for this service
210
        # Note this analysis might not belong to this current instance, but
211
        # from a descendant (partition)
212
        analysis = self.resolve_analysis(instance, service)
213
        if not analysis:
214
            # Create the analysis
215
            new_analysis = True
216
            keyword = service.getKeyword()
217
            logger.info("Creating new analysis '{}'".format(keyword))
218
            analysis = create_analysis(instance, service)
219
220
        # Set the hidden status
221
        analysis.setHidden(hidden.get(service_uid, False))
222
223
        # Set the price of the Analysis
224
        analysis.setPrice(prices.get(service_uid, service.getPrice()))
225
226
        # Only return the analysis if is a new one
227
        if new_analysis:
228
            return analysis
229
230
        return None
231
232
    def resolve_analysis(self, instance, service):
233
        """Resolves an analysis for the service and instance
234
        """
235
        # Does the analysis exists in this instance already?
236
        analysis = self.get_from_instance(instance, service)
237
        if analysis:
238
            keyword = service.getKeyword()
239
            logger.info("Analysis for '{}' already exists".format(keyword))
240
            return analysis
241
242
        # Does the analysis exists in an ancestor?
243
        from_ancestor = self.get_from_ancestor(instance, service)
244
        if from_ancestor:
245
            # Move the analysis into this instance. The ancestor's
246
            # analysis will be masked otherwise
247
            analysis_id = api.get_id(from_ancestor)
248
            logger.info("Analysis {} is from an ancestor".format(analysis_id))
249
            cp = from_ancestor.aq_parent.manage_cutObjects(analysis_id)
250
            instance.manage_pasteObjects(cp)
251
            return instance._getOb(analysis_id)
252
253
        # Does the analysis exists in a descendant?
254
        from_descendant = self.get_from_descendant(instance, service)
255
        if from_descendant:
256
            # The analysis already exists in a partition, keep it. The
257
            # analysis from current instance will be masked otherwise
258
            analysis_id = api.get_id(from_descendant)
259
            logger.info("Analysis {} is from a descendant".format(analysis_id))
260
            return from_descendant
261
262
        return None
263
264
    def get_analyses_from_descendants(self, instance):
265
        """Returns all the analyses from descendants
266
        """
267
        analyses = []
268
        for descendant in instance.getDescendants(all_descendants=True):
269
            analyses.extend(descendant.objectValues("Analysis"))
270
        return analyses
271
272
    def get_from_instance(self, instance, service):
273
        """Returns an analysis for the given service from the instance
274
        """
275
        service_uid = api.get_uid(service)
276
        for analysis in instance.objectValues("Analysis"):
277
            if analysis.getServiceUID() == service_uid:
278
                return analysis
279
        return None
280
281
    def get_from_ancestor(self, instance, service):
282
        """Returns an analysis for the given service from ancestors
283
        """
284
        ancestor = instance.getParentAnalysisRequest()
285
        if not ancestor:
286
            return None
287
288
        analysis = self.get_from_instance(ancestor, service)
289
        return analysis or self.get_from_ancestor(ancestor, service)
290
291
    def get_from_descendant(self, instance, service):
292
        """Returns an analysis for the given service from descendants
293
        """
294
        for descendant in instance.getDescendants():
295
            # Does the analysis exists in the current descendant?
296
            analysis = self.get_from_instance(descendant, service)
297
            if analysis:
298
                return analysis
299
300
            # Search in descendants from current descendant
301
            analysis = self.get_from_descendant(descendant, service)
302
            if analysis:
303
                return analysis
304
305
        return None
306
307
    def _get_services(self, full_objects=False):
308
        """Fetch and return analysis service objects
309
        """
310
        bsc = api.get_tool("bika_setup_catalog")
311
        brains = bsc(portal_type="AnalysisService")
312
        if full_objects:
313
            return map(api.get_object, brains)
314
        return brains
315
316
    def _to_service(self, thing):
317
        """Convert to Analysis Service
318
319
        :param thing: UID/Catalog Brain/Object/Something
320
        :returns: Analysis Service object or None
321
        """
322
323
        # Convert UIDs to objects
324
        if api.is_uid(thing):
325
            thing = api.get_object_by_uid(thing, None)
326
327
        # Bail out if the thing is not a valid object
328
        if not api.is_object(thing):
329
            logger.warn("'{}' is not a valid object!".format(repr(thing)))
330
            return None
331
332
        # Ensure we have an object here and not a brain
333
        obj = api.get_object(thing)
334
335
        if IAnalysisService.providedBy(obj):
336
            return obj
337
338
        if IAnalysis.providedBy(obj):
339
            return obj.getAnalysisService()
340
341
        # An object, but neither an Analysis nor AnalysisService?
342
        # This should never happen.
343
        portal_type = api.get_portal_type(obj)
344
        logger.error("ARAnalysesField doesn't accept objects from {} type. "
345
                     "The object will be dismissed.".format(portal_type))
346
        return None
347
348
    def _update_specs(self, instance, specs):
349
        """Update AR specifications
350
351
        :param instance: Analysis Request
352
        :param specs: List of Specification Records
353
        """
354
355
        if specs is None:
356
            return
357
358
        # N.B. we copy the records here, otherwise the spec will be written to
359
        #      the attached specification of this AR
360
        rr = {item["keyword"]: item.copy()
361
              for item in instance.getResultsRange()}
362
        for spec in specs:
363
            keyword = spec.get("keyword")
364
            if keyword in rr:
365
                # overwrite the instance specification only, if the specific
366
                # analysis spec has min/max values set
367
                if all([spec.get("min"), spec.get("max")]):
368
                    rr[keyword].update(spec)
369
                else:
370
                    rr[keyword] = spec
371
            else:
372
                rr[keyword] = spec
373
        return instance.setResultsRange(rr.values())
374
375
376
registerField(ARAnalysesField,
377
              title="Analyses",
378
              description="Manages Analyses of ARs")
379