Passed
Push — master ( ac1405...5f2185 )
by Ramon
05:23
created

bika.lims.utils.analysisrequest   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 445
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 53
eloc 246
dl 0
loc 445
rs 6.96
c 0
b 0
f 0

7 Functions

Rating   Name   Duplication   Size   Complexity  
B create_analysisrequest() 0 58 5
C notify_rejection() 0 80 8
B _resolve_items_to_service_uids() 0 37 7
C get_services_uids() 0 48 11
C create_retest() 0 70 9
A fields_to_dict() 0 14 5
C create_partition() 0 88 8

How to fix   Complexity   

Complexity

Complex classes like bika.lims.utils.analysisrequest often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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 os
9
import tempfile
10
from email.mime.multipart import MIMEMultipart
11
from email.mime.text import MIMEText
12
from email.Utils import formataddr
13
14
from bika.lims import api
15
from bika.lims import bikaMessageFactory as _
16
from bika.lims import logger
17
from bika.lims.idserver import renameAfterCreation
18
from bika.lims.interfaces import IAnalysisRequest
19
from bika.lims.interfaces import IAnalysisRequestRetest
20
from bika.lims.interfaces import IAnalysisService
21
from bika.lims.interfaces import IRoutineAnalysis
22
from bika.lims.utils import attachPdf
23
from bika.lims.utils import changeWorkflowState
24
from bika.lims.utils import copy_field_values
25
from bika.lims.utils import createPdf
26
from bika.lims.utils import encode_header
27
from bika.lims.utils import tmpID
28
from bika.lims.utils import to_utf8
29
from bika.lims.workflow import ActionHandlerPool
30
from bika.lims.workflow import doActionFor
31
from bika.lims.workflow import push_reindex_to_actions_pool
32
from Products.CMFCore.utils import getToolByName
33
from Products.CMFPlone.utils import _createObjectByType
34
from Products.CMFPlone.utils import safe_unicode
35
from zope.interface import alsoProvides
36
37
38
def create_analysisrequest(client, request, values, analyses=None,
39
                           partitions=None, specifications=None, prices=None):
40
    """This is meant for general use and should do everything necessary to
41
    create and initialise an AR and any other required auxilliary objects
42
    (Sample, SamplePartition, Analysis...)
43
    :param client:
44
        The container (Client) in which the ARs will be created.
45
    :param request:
46
        The current Request object.
47
    :param values:
48
        a dict, where keys are AR|Sample schema field names.
49
    :param analyses:
50
        Analysis services list.  If specified, augments the values in
51
        values['Analyses']. May consist of service objects, UIDs, or Keywords.
52
    :param partitions:
53
        A list of dictionaries, if specific partitions are required.  If not
54
        specified, AR's sample is created with a single partition.
55
    :param specifications:
56
        These values augment those found in values['Specifications']
57
    :param prices:
58
        Allow different prices to be set for analyses.  If not set, prices
59
        are read from the associated analysis service.
60
    """
61
    # Don't pollute the dict param passed in
62
    values = dict(values.items())
63
64
    # Create new sample or locate the existing for secondary AR
65
    secondary = False
66
    # TODO Sample Cleanup - Manage secondary ARs properly
67
68
    # Create the Analysis Request
69
    ar = _createObjectByType('AnalysisRequest', client, tmpID())
70
    ar.processForm(REQUEST=request, values=values)
71
72
    # Resolve the services uids and set the analyses for this Analysis Request
73
    service_uids = get_services_uids(context=client, values=values,
74
                                     analyses_serv=analyses)
75
    ar.setAnalyses(service_uids, prices=prices, specs=specifications)
76
77
    # TODO Sample Cleanup - Manage secondary ARs properly
78
    if secondary:
79
        # Secondary AR does not longer comes from a Sample, rather from an AR.
80
        # If the Primary AR has been received, then force the transition of the
81
        # secondary to received and set the description/comment in the
82
        # transition accordingly so it will be displayed later in the log tab
83
        logger.warn("Sync transition for secondary AR is still missing")
84
85
    # Needs to be rejected from the very beginning?
86
    reject_field = values.get("RejectionReasons", None)
87
    if reject_field and reject_field.get("checkbox", False):
88
        doActionFor(ar, "reject")
89
        return ar
90
91
    # Try first with no sampling transition, cause it is the most common config
92
    success, message = doActionFor(ar, "no_sampling_workflow")
93
    if not success:
94
        doActionFor(ar, "to_be_sampled")
95
    return ar
96
97
98
def get_services_uids(context=None, analyses_serv=None, values=None):
99
    """
100
    This function returns a list of UIDs from analyses services from its
101
    parameters.
102
    :param analyses_serv: A list (or one object) of service-related info items.
103
        see _resolve_items_to_service_uids() docstring.
104
    :type analyses_serv: list
105
    :param values: a dict, where keys are AR|Sample schema field names.
106
    :type values: dict
107
    :returns: a list of analyses services UIDs
108
    """
109
    if not analyses_serv:
110
        analyses_serv = []
111
    if not values:
112
        values = {}
113
114
    if not context or (not analyses_serv and not values):
115
        raise RuntimeError(
116
            "get_services_uids: Missing or wrong parameters.")
117
118
    # Merge analyses from analyses_serv and values into one list
119
    analyses_services = analyses_serv + (values.get("Analyses", None) or [])
120
121
    # It is possible to create analysis requests
122
    # by JSON petitions and services, profiles or types aren't allways send.
123
    # Sometimes we can get analyses and profiles that doesn't match and we
124
    # should act in consequence.
125
    # Getting the analyses profiles
126
    analyses_profiles = values.get('Profiles', [])
127
    if not isinstance(analyses_profiles, (list, tuple)):
128
        # Plone converts the incoming form value to a list, if there are
129
        # multiple values; but if not, it will send a string (a single UID).
130
        analyses_profiles = [analyses_profiles]
131
132
    if not analyses_services and not analyses_profiles:
133
        return []
134
135
    # Add analysis services UIDs from profiles to analyses_services variable.
136
    if analyses_profiles:
137
        uid_catalog = getToolByName(context, 'uid_catalog')
138
        for brain in uid_catalog(UID=analyses_profiles):
139
            profile = api.get_object(brain)
140
            # Only services UIDs
141
            services_uids = profile.getRawService()
142
            # _resolve_items_to_service_uids() will remove duplicates
143
            analyses_services += services_uids
144
145
    return _resolve_items_to_service_uids(analyses_services)
146
147
148
def _resolve_items_to_service_uids(items):
149
    """ Returns a list of service uids without duplicates based on the items
150
    :param items:
151
        A list (or one object) of service-related info items. The list can be
152
        heterogeneous and each item can be:
153
        - Analysis Service instance
154
        - Analysis instance
155
        - Analysis Service title
156
        - Analysis Service UID
157
        - Analysis Service Keyword
158
        If an item that doesn't match any of the criterias above is found, the
159
        function will raise a RuntimeError
160
    """
161
    def resolve_to_uid(item):
162
        if api.is_uid(item):
163
            return item
164
        elif IAnalysisService.providedBy(item):
165
            return item.UID()
166
        elif IRoutineAnalysis.providedBy(item):
167
            return item.getServiceUID()
168
169
        bsc = api.get_tool("bika_setup_catalog")
170
        brains = bsc(portal_type='AnalysisService', getKeyword=item)
171
        if brains:
172
            return brains[0].UID
173
        brains = bsc(portal_type='AnalysisService', title=item)
174
        if brains:
175
            return brains[0].UID
176
        raise RuntimeError(
177
            str(item) + " should be the UID, title, keyword "
178
                        " or title of an AnalysisService.")
179
180
    # Maybe only a single item was passed
181
    if type(items) not in (list, tuple):
182
        items = [items, ]
183
    service_uids = map(resolve_to_uid, list(set(items)))
184
    return list(set(service_uids))
185
186
187
def notify_rejection(analysisrequest):
188
    """
189
    Notifies via email that a given Analysis Request has been rejected. The
190
    notification is sent to the Client contacts assigned to the Analysis
191
    Request.
192
193
    :param analysisrequest: Analysis Request to which the notification refers
194
    :returns: true if success
195
    """
196
197
    # We do this imports here to avoid circular dependencies until we deal
198
    # better with this notify_rejection thing.
199
    from bika.lims.browser.analysisrequest.reject import \
200
        AnalysisRequestRejectPdfView, AnalysisRequestRejectEmailView
201
202
    arid = analysisrequest.getId()
203
204
    # This is the template to render for the pdf that will be either attached
205
    # to the email and attached the the Analysis Request for further access
206
    tpl = AnalysisRequestRejectPdfView(analysisrequest, analysisrequest.REQUEST)
207
    html = tpl.template()
208
    html = safe_unicode(html).encode('utf-8')
209
    filename = '%s-rejected' % arid
210
    pdf_fn = tempfile.mktemp(suffix=".pdf")
211
    pdf = createPdf(htmlreport=html, outfile=pdf_fn)
212
    if pdf:
213
        # Attach the pdf to the Analysis Request
214
        attid = analysisrequest.aq_parent.generateUniqueId('Attachment')
215
        att = _createObjectByType(
216
            "Attachment", analysisrequest.aq_parent, attid)
217
        att.setAttachmentFile(open(pdf_fn))
218
        # Awkward workaround to rename the file
219
        attf = att.getAttachmentFile()
220
        attf.filename = '%s.pdf' % filename
221
        att.setAttachmentFile(attf)
222
        att.unmarkCreationFlag()
223
        renameAfterCreation(att)
224
        atts = analysisrequest.getAttachment() + [att] if \
225
            analysisrequest.getAttachment() else [att]
226
        atts = [a.UID() for a in atts]
227
        analysisrequest.setAttachment(atts)
228
        os.remove(pdf_fn)
229
230
    # This is the message for the email's body
231
    tpl = AnalysisRequestRejectEmailView(
232
        analysisrequest, analysisrequest.REQUEST)
233
    html = tpl.template()
234
    html = safe_unicode(html).encode('utf-8')
235
236
    # compose and send email.
237
    mailto = []
238
    lab = analysisrequest.bika_setup.laboratory
239
    mailfrom = formataddr((encode_header(lab.getName()), lab.getEmailAddress()))
240
    mailsubject = _('%s has been rejected') % arid
241
    contacts = [analysisrequest.getContact()] + analysisrequest.getCCContact()
242
    for contact in contacts:
243
        name = to_utf8(contact.getFullname())
244
        email = to_utf8(contact.getEmailAddress())
245
        if email:
246
            mailto.append(formataddr((encode_header(name), email)))
247
    if not mailto:
248
        return False
249
    mime_msg = MIMEMultipart('related')
250
    mime_msg['Subject'] = mailsubject
251
    mime_msg['From'] = mailfrom
252
    mime_msg['To'] = ','.join(mailto)
253
    mime_msg.preamble = 'This is a multi-part MIME message.'
254
    msg_txt = MIMEText(html, _subtype='html')
255
    mime_msg.attach(msg_txt)
256
    if pdf:
257
        attachPdf(mime_msg, pdf, filename)
258
259
    try:
260
        host = getToolByName(analysisrequest, 'MailHost')
261
        host.send(mime_msg.as_string(), immediate=True)
262
    except:
263
        logger.warning(
264
            "Email with subject %s was not sent (SMTP connection error)" % mailsubject)
265
266
    return True
267
268
269
def create_retest(ar):
270
    """Creates a retest (Analysis Request) from an invalidated Analysis Request
271
    :param ar: The invalidated Analysis Request
272
    :type ar: IAnalysisRequest
273
    :rtype: IAnalysisRequest
274
    """
275
    if not ar:
276
        raise ValueError("Source Analysis Request cannot be None")
277
278
    if not IAnalysisRequest.providedBy(ar):
279
        raise ValueError("Type not supported: {}".format(repr(type(ar))))
280
281
    if ar.getRetest():
282
        # Do not allow the creation of another retest!
283
        raise ValueError("Retest already set")
284
285
    if not ar.isInvalid():
286
        # Analysis Request must be in 'invalid' state
287
        raise ValueError("Cannot do a retest from an invalid Analysis Request"
288
                         .format(repr(ar)))
289
290
    # Open the actions pool
291
    actions_pool = ActionHandlerPool.get_instance()
292
    actions_pool.queue_pool()
293
294
    # Create the Retest (Analysis Request)
295
    ignore = ['Analyses', 'DatePublished', 'Invalidated', 'Sample']
296
    retest = _createObjectByType("AnalysisRequest", ar.aq_parent, tmpID())
297
    copy_field_values(ar, retest, ignore_fieldnames=ignore)
298
299
    # Mark the retest with the `IAnalysisRequestRetest` interface
300
    alsoProvides(retest, IAnalysisRequestRetest)
301
302
    # Assign the source to retest
303
    retest.setInvalidated(ar)
304
305
    # Rename the retest according to the ID server setup
306
    renameAfterCreation(retest)
307
308
    # Copy the analyses from the source
309
    intermediate_states = ['retracted', 'reflexed']
310
    for an in ar.getAnalyses(full_objects=True):
311
        if (api.get_workflow_status_of(an) in intermediate_states):
312
            # Exclude intermediate analyses
313
            continue
314
315
        nan = _createObjectByType("Analysis", retest, an.getKeyword())
316
317
        # Make a copy
318
        ignore_fieldnames = ['DataAnalysisPublished']
319
        copy_field_values(an, nan, ignore_fieldnames=ignore_fieldnames)
320
        nan.unmarkCreationFlag()
321
        push_reindex_to_actions_pool(nan)
322
323
    # Transition the retest to "sample_received"!
324
    changeWorkflowState(retest, 'bika_ar_workflow', 'sample_received')
325
326
    # Initialize analyses
327
    for analysis in retest.getAnalyses(full_objects=True):
328
        if not IRoutineAnalysis.providedBy(analysis):
329
            continue
330
        changeWorkflowState(analysis, "bika_analysis_workflow", "unassigned")
331
332
    # Reindex and other stuff
333
    push_reindex_to_actions_pool(retest)
334
    push_reindex_to_actions_pool(retest.aq_parent)
335
336
    # Resume the actions pool
337
    actions_pool.resume()
338
    return retest
339
340
341
def create_partition(analysis_request, request, analyses, sample_type=None,
342
                     container=None, preservation=None, skip_fields=None,
343
                     remove_primary_analyses=True):
344
    """
345
    Creates a partition for the analysis_request (primary) passed in
346
    :param analysis_request: uid/brain/object of IAnalysisRequest type
347
    :param request: the current request object
348
    :param analyses: uids/brains/objects of IAnalysis type
349
    :param sampletype: uid/brain/object of SampleType
350
    :param container: uid/brain/object of Container
351
    :param preservation: uid/brain/object of Preservation
352
    :param skip_fields: names of fields to be skipped on copy from primary
353
    :param remove_primary_analyses: removes the analyses from the parent
354
    :return: the new partition
355
    """
356
    partition_skip_fields = [
357
        "Analyses",
358
        "Attachment",
359
        "Client",
360
        "Profile",
361
        "Profiles",
362
        "RejectionReasons",
363
        "Remarks",
364
        "ResultsInterpretation",
365
        "ResultsInterpretationDepts",
366
        "Sample",
367
        "Template",
368
        "creation_date",
369
        "id",
370
        "modification_date",
371
        "ParentAnalysisRequest",
372
    ]
373
    if skip_fields:
374
        partition_skip_fields.extend(skip_fields)
375
        partition_skip_fields = list(set(partition_skip_fields))
376
377
    # Copy field values from the primary analysis request
378
    ar = api.get_object(analysis_request)
379
    record = fields_to_dict(ar, partition_skip_fields)
380
381
    # Update with values that are partition-specific
382
    record.update({
383
        "InternalUse": True,
384
        "ParentAnalysisRequest": api.get_uid(ar),
385
    })
386
    if sample_type is not None:
387
        record["SampleType"] = sample_type and api.get_uid(sample_type) or ""
388
    if container is not None:
389
        record["Container"] = container and api.get_uid(container) or ""
390
    if preservation is not None:
391
        record["Preservation"] = preservation and api.get_uid(preservation) or ""
392
393
    # Create the Partition
394
    client = ar.getClient()
395
    analyses = list(set(map(api.get_object, analyses)))
396
    services = map(lambda an: an.getAnalysisService(), analyses)
397
    specs = ar.getSpecification()
398
    specs = specs and specs.getResultsRange() or []
399
    partition = create_analysisrequest(client, request=request, values=record,
400
                                       analyses=services, specifications=specs)
401
402
    # Remove analyses from the primary
403
    if remove_primary_analyses:
404
        analyses_ids = map(api.get_id, analyses)
405
        ar.manage_delObjects(analyses_ids)
406
407
    # Reindex Parent Analysis Request
408
    ar.reindexObject(idxs=["isRootAncestor"])
409
410
    # Manually set the Date Received to match with its parent. This is
411
    # necessary because crar calls to processForm, so DateReceived is not
412
    # set because the partition has not been received yet
413
    partition.setDateReceived(ar.getDateReceived())
414
    partition.reindexObject(idxs="getDateReceived")
415
416
    # Force partition to same status as the primary
417
    status = api.get_workflow_status_of(ar)
418
    changeWorkflowState(partition, "bika_ar_workflow", status)
419
420
    # And initialize the analyses the partition contains. This is required
421
    # here because the transition "initialize" of analyses rely on a guard,
422
    # so the initialization can only be performed when the sample has been
423
    # received (DateReceived is set)
424
    ActionHandlerPool.get_instance().queue_pool()
425
    for analysis in partition.getAnalyses(full_objects=True):
426
        doActionFor(analysis, "initialize")
427
    ActionHandlerPool.get_instance().resume()
428
    return partition
429
430
431
def fields_to_dict(obj, skip_fields=None):
432
    """
433
    Generates a dictionary with the field values of the object passed in, where
434
    keys are the field names. Skips computed fields
435
    """
436
    data = {}
437
    obj = api.get_object(obj)
438
    for field_name, field in api.get_fields(obj).items():
439
        if skip_fields and field_name in skip_fields:
440
            continue
441
        if field.type == "computed":
442
            continue
443
        data[field_name] = field.get(obj)
444
    return data
445