Passed
Push — master ( a164aa...cff61d )
by Ramon
03:42
created

bika.lims.utils.analysisrequest.do_rejection()   B

Complexity

Conditions 6

Size

Total Lines 33
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 19
dl 0
loc 33
rs 8.5166
c 0
b 0
f 0
cc 6
nop 2
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-2020 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import itertools
22
from string import Template
23
24
import six
25
from Products.Archetypes.config import UID_CATALOG
26
from Products.CMFCore.utils import getToolByName
27
from Products.CMFPlone.utils import _createObjectByType
28
from Products.CMFPlone.utils import safe_unicode
29
from zope.interface import alsoProvides
30
from zope.lifecycleevent import modified
31
32
from bika.lims import api
33
from bika.lims import bikaMessageFactory as _
34
from bika.lims import logger
35
from bika.lims.api.mail import compose_email
36
from bika.lims.api.mail import is_valid_email_address
37
from bika.lims.api.mail import send_email
38
from bika.lims.catalog import SETUP_CATALOG
39
from bika.lims.idserver import renameAfterCreation
40
from bika.lims.interfaces import IAnalysisRequest
41
from bika.lims.interfaces import IAnalysisRequestRetest
42
from bika.lims.interfaces import IAnalysisRequestSecondary
43
from bika.lims.interfaces import IAnalysisService
44
from bika.lims.interfaces import IReceived
45
from bika.lims.interfaces import IRoutineAnalysis
46
from bika.lims.utils import changeWorkflowState
47
from bika.lims.utils import copy_field_values
48
from bika.lims.utils import createPdf
49
from bika.lims.utils import get_link
50
from bika.lims.utils import tmpID
51
from bika.lims.workflow import ActionHandlerPool
52
from bika.lims.workflow import doActionFor
53
from bika.lims.workflow import push_reindex_to_actions_pool
54
from bika.lims.workflow.analysisrequest import AR_WORKFLOW_ID
55
from bika.lims.workflow.analysisrequest import do_action_to_analyses
56
57
58
def create_analysisrequest(client, request, values, analyses=None,
59
                           results_ranges=None, prices=None):
60
    """Creates a new AnalysisRequest (a Sample) object
61
    :param client: The container where the Sample will be created
62
    :param request: The current Http Request object
63
    :param values: A dict, with keys as AnalaysisRequest's schema field names
64
    :param analyses: List of Services or Analyses (brains, objects, UIDs,
65
        keywords). Extends the list from values["Analyses"]
66
    :param results_ranges: List of Results Ranges. Extends the results ranges
67
        from the Specification object defined in values["Specification"]
68
    :param prices: Mapping of AnalysisService UID -> price. If not set, prices
69
        are read from the associated analysis service.
70
    """
71
    # Don't pollute the dict param passed in
72
    values = dict(values.items())
73
74
    # Resolve the Service uids of analyses to be added in the Sample. Values
75
    # passed-in might contain Profiles and also values that are not uids. Also,
76
    # additional analyses can be passed-in through either values or services
77
    service_uids = to_services_uids(values=values, services=analyses)
78
79
    # Remove the Analyses from values. We will add them manually
80
    values.update({"Analyses": []})
81
82
    # Create the Analysis Request and submit the form
83
    ar = _createObjectByType('AnalysisRequest', client, tmpID())
84
    ar.processForm(REQUEST=request, values=values)
85
86
    # Set the analyses manually
87
    ar.setAnalyses(service_uids, prices=prices, specs=results_ranges)
88
89
    # Handle hidden analyses from template and profiles
90
    # https://github.com/senaite/senaite.core/issues/1437
91
    # https://github.com/senaite/senaite.core/issues/1326
92
    apply_hidden_services(ar)
93
94
    # Handle rejection reasons
95
    rejection_reasons = resolve_rejection_reasons(values)
96
    ar.setRejectionReasons(rejection_reasons)
97
98
    # Handle secondary Analysis Request
99
    primary = ar.getPrimaryAnalysisRequest()
100
    if primary:
101
        # Mark the secondary with the `IAnalysisRequestSecondary` interface
102
        alsoProvides(ar, IAnalysisRequestSecondary)
103
104
        # Rename the secondary according to the ID server setup
105
        renameAfterCreation(ar)
106
107
        # Set dates to match with those from the primary
108
        ar.setDateSampled(primary.getDateSampled())
109
        ar.setSamplingDate(primary.getSamplingDate())
110
        ar.setDateReceived(primary.getDateReceived())
111
112
        # Force the transition of the secondary to received and set the
113
        # description/comment in the transition accordingly.
114
        if primary.getDateReceived():
115
            primary_id = primary.getId()
116
            comment = "Auto-received. Secondary Sample of {}".format(primary_id)
117
            changeWorkflowState(ar, AR_WORKFLOW_ID, "sample_received",
118
                                action="receive", comments=comment)
119
120
            # Mark the secondary as received
121
            alsoProvides(ar, IReceived)
122
123
            # Initialize analyses
124
            do_action_to_analyses(ar, "initialize")
125
126
            # Notify the ar has ben modified
127
            modified(ar)
128
129
            # Reindex the AR
130
            ar.reindexObject()
131
132
            # If rejection reasons have been set, reject automatically
133
            if rejection_reasons:
134
                do_rejection(ar)
135
136
            # In "received" state already
137
            return ar
138
139
    # Try first with no sampling transition, cause it is the most common config
140
    success, message = doActionFor(ar, "no_sampling_workflow")
141
    if not success:
142
        doActionFor(ar, "to_be_sampled")
143
144
    # If rejection reasons have been set, reject the sample automatically
145
    if rejection_reasons:
146
        do_rejection(ar)
147
148
    return ar
149
150
151
def apply_hidden_services(sample):
152
    """
153
    Applies the hidden setting to the sample analyses in accordance with the
154
    settings from its template and/or profiles
155
    :param sample: the sample that contains the analyses
156
    """
157
    hidden = list()
158
159
    # Get the "hidden" service uids from the template
160
    template = sample.getTemplate()
161
    hidden = get_hidden_service_uids(template)
162
163
    # Get the "hidden" service uids from profiles
164
    profiles = sample.getProfiles()
165
    hid_profiles = map(get_hidden_service_uids, profiles)
166
    hid_profiles = list(itertools.chain(*hid_profiles))
167
    hidden.extend(hid_profiles)
168
169
    # Update the sample analyses
170
    analyses = sample.getAnalyses(full_objects=True)
171
    analyses = filter(lambda an: an.getServiceUID() in hidden, analyses)
172
    for analysis in analyses:
173
        analysis.setHidden(True)
174
175
176
def get_hidden_service_uids(profile_or_template):
177
    """Returns a list of service uids that are set as hidden
178
    :param profile_or_template: ARTemplate or AnalysisProfile object
179
    """
180
    if not profile_or_template:
181
        return []
182
    settings = profile_or_template.getAnalysisServicesSettings()
183
    hidden = filter(lambda ser: ser.get("hidden", False), settings)
184
    return map(lambda setting: setting["uid"], hidden)
185
186
187
def to_services_uids(services=None, values=None):
188
    """
189
    Returns a list of Analysis Services uids
190
    :param services: A list of service items (uid, keyword, brain, obj, title)
191
    :param values: a dict, where keys are AR|Sample schema field names.
192
    :returns: a list of Analyses Services UIDs
193
    """
194
    def to_list(value):
195
        if not value:
196
            return []
197
        if isinstance(value, six.string_types):
198
            return [value]
199
        if isinstance(value, (list, tuple)):
200
            return value
201
        logger.warn("Cannot convert to a list: {}".format(value))
202
        return []
203
204
    services = services or []
205
    values = values or {}
206
207
    # Merge analyses from analyses_serv and values into one list
208
    uids = to_list(services) + to_list(values.get("Analyses"))
209
210
    # Convert them to a list of service uids
211
    uids = filter(None, map(to_service_uid, uids))
212
213
    # Extend with service uids from profiles
214
    profiles = to_list(values.get("Profiles"))
215
    if profiles:
216
        uid_catalog = api.get_tool(UID_CATALOG)
217
        for brain in uid_catalog(UID=profiles):
218
            profile = api.get_object(brain)
219
            uids.extend(profile.getRawService() or [])
220
221
    # Get the service uids without duplicates, but preserving the order
222
    return list(dict.fromkeys(uids).keys())
223
224
225
def to_service_uid(uid_brain_obj_str):
226
    """Resolves the passed in element to a valid uid. Returns None if the value
227
    cannot be resolved to a valid uid
228
    """
229
    if api.is_uid(uid_brain_obj_str) and uid_brain_obj_str != "0":
230
        return uid_brain_obj_str
231
232
    if api.is_object(uid_brain_obj_str):
233
        obj = api.get_object(uid_brain_obj_str)
234
235
        if IAnalysisService.providedBy(obj):
236
            return api.get_uid(obj)
237
238
        elif IRoutineAnalysis.providedBy(obj):
239
            return obj.getServiceUID()
240
241
        else:
242
            logger.error("Type not supported: {}".format(obj.portal_type))
243
            return None
244
245
    if isinstance(uid_brain_obj_str, six.string_types):
246
        # Maybe is a keyword?
247
        query = dict(portal_type="AnalysisService", getKeyword=uid_brain_obj_str)
248
        brains = api.search(query, SETUP_CATALOG)
249
        if len(brains) == 1:
250
            return api.get_uid(brains[0])
251
252
        # Or maybe a title
253
        query = dict(portal_type="AnalysisService", title=uid_brain_obj_str)
254
        brains = api.search(query, SETUP_CATALOG)
255
        if len(brains) == 1:
256
            return api.get_uid(brains[0])
257
258
    return None
259
260
261
def create_retest(ar):
262
    """Creates a retest (Analysis Request) from an invalidated Analysis Request
263
    :param ar: The invalidated Analysis Request
264
    :type ar: IAnalysisRequest
265
    :rtype: IAnalysisRequest
266
    """
267
    if not ar:
268
        raise ValueError("Source Analysis Request cannot be None")
269
270
    if not IAnalysisRequest.providedBy(ar):
271
        raise ValueError("Type not supported: {}".format(repr(type(ar))))
272
273
    if ar.getRetest():
274
        # Do not allow the creation of another retest!
275
        raise ValueError("Retest already set")
276
277
    if not ar.isInvalid():
278
        # Analysis Request must be in 'invalid' state
279
        raise ValueError("Cannot do a retest from an invalid Analysis Request"
280
                         .format(repr(ar)))
281
282
    # Open the actions pool
283
    actions_pool = ActionHandlerPool.get_instance()
284
    actions_pool.queue_pool()
285
286
    # Create the Retest (Analysis Request)
287
    ignore = ['Analyses', 'DatePublished', 'Invalidated', 'Sample']
288
    retest = _createObjectByType("AnalysisRequest", ar.aq_parent, tmpID())
289
    copy_field_values(ar, retest, ignore_fieldnames=ignore)
290
291
    # Mark the retest with the `IAnalysisRequestRetest` interface
292
    alsoProvides(retest, IAnalysisRequestRetest)
293
294
    # Assign the source to retest
295
    retest.setInvalidated(ar)
296
297
    # Rename the retest according to the ID server setup
298
    renameAfterCreation(retest)
299
300
    # Copy the analyses from the source
301
    intermediate_states = ['retracted', 'reflexed']
302
    for an in ar.getAnalyses(full_objects=True):
303
        if (api.get_workflow_status_of(an) in intermediate_states):
304
            # Exclude intermediate analyses
305
            continue
306
307
        nan = _createObjectByType("Analysis", retest, an.getKeyword())
308
309
        # Make a copy
310
        ignore_fieldnames = ['DataAnalysisPublished']
311
        copy_field_values(an, nan, ignore_fieldnames=ignore_fieldnames)
312
        nan.unmarkCreationFlag()
313
        push_reindex_to_actions_pool(nan)
314
315
    # Transition the retest to "sample_received"!
316
    changeWorkflowState(retest, 'bika_ar_workflow', 'sample_received')
317
    alsoProvides(retest, IReceived)
318
319
    # Initialize analyses
320
    for analysis in retest.getAnalyses(full_objects=True):
321
        if not IRoutineAnalysis.providedBy(analysis):
322
            continue
323
        changeWorkflowState(analysis, "bika_analysis_workflow", "unassigned")
324
325
    # Reindex and other stuff
326
    push_reindex_to_actions_pool(retest)
327
    push_reindex_to_actions_pool(retest.aq_parent)
328
329
    # Resume the actions pool
330
    actions_pool.resume()
331
    return retest
332
333
334
def create_partition(analysis_request, request, analyses, sample_type=None,
335
                     container=None, preservation=None, skip_fields=None,
336
                     internal_use=True):
337
    """
338
    Creates a partition for the analysis_request (primary) passed in
339
    :param analysis_request: uid/brain/object of IAnalysisRequest type
340
    :param request: the current request object
341
    :param analyses: uids/brains/objects of IAnalysis type
342
    :param sampletype: uid/brain/object of SampleType
343
    :param container: uid/brain/object of Container
344
    :param preservation: uid/brain/object of Preservation
345
    :param skip_fields: names of fields to be skipped on copy from primary
346
    :return: the new partition
347
    """
348
    partition_skip_fields = [
349
        "Analyses",
350
        "Attachment",
351
        "Client",
352
        "DetachedFrom",
353
        "Profile",
354
        "Profiles",
355
        "RejectionReasons",
356
        "Remarks",
357
        "ResultsInterpretation",
358
        "ResultsInterpretationDepts",
359
        "Sample",
360
        "Template",
361
        "creation_date",
362
        "id",
363
        "modification_date",
364
        "ParentAnalysisRequest",
365
        "PrimaryAnalysisRequest",
366
    ]
367
    if skip_fields:
368
        partition_skip_fields.extend(skip_fields)
369
        partition_skip_fields = list(set(partition_skip_fields))
370
371
    # Copy field values from the primary analysis request
372
    ar = api.get_object(analysis_request)
373
    record = fields_to_dict(ar, partition_skip_fields)
374
375
    # Update with values that are partition-specific
376
    record.update({
377
        "InternalUse": internal_use,
378
        "ParentAnalysisRequest": api.get_uid(ar),
379
    })
380
    if sample_type is not None:
381
        record["SampleType"] = sample_type and api.get_uid(sample_type) or ""
382
    if container is not None:
383
        record["Container"] = container and api.get_uid(container) or ""
384
    if preservation is not None:
385
        record["Preservation"] = preservation and api.get_uid(preservation) or ""
386
387
    # Create the Partition
388
    client = ar.getClient()
389
    analyses = list(set(map(api.get_object, analyses)))
390
    services = map(lambda an: an.getAnalysisService(), analyses)
391
392
    # Populate the root's ResultsRanges to partitions
393
    results_ranges = ar.getResultsRange() or []
394
    partition = create_analysisrequest(client,
395
                                       request=request,
396
                                       values=record,
397
                                       analyses=services,
398
                                       results_ranges=results_ranges)
399
400
    # Reindex Parent Analysis Request
401
    ar.reindexObject(idxs=["isRootAncestor"])
402
403
    # Manually set the Date Received to match with its parent. This is
404
    # necessary because crar calls to processForm, so DateReceived is not
405
    # set because the partition has not been received yet
406
    partition.setDateReceived(ar.getDateReceived())
407
    partition.reindexObject(idxs="getDateReceived")
408
409
    # Force partition to same status as the primary
410
    status = api.get_workflow_status_of(ar)
411
    changeWorkflowState(partition, "bika_ar_workflow", status)
412
    if IReceived.providedBy(ar):
413
        alsoProvides(partition, IReceived)
414
415
    # And initialize the analyses the partition contains. This is required
416
    # here because the transition "initialize" of analyses rely on a guard,
417
    # so the initialization can only be performed when the sample has been
418
    # received (DateReceived is set)
419
    ActionHandlerPool.get_instance().queue_pool()
420
    for analysis in partition.getAnalyses(full_objects=True):
421
        doActionFor(analysis, "initialize")
422
    ActionHandlerPool.get_instance().resume()
423
    return partition
424
425
426
def fields_to_dict(obj, skip_fields=None):
427
    """
428
    Generates a dictionary with the field values of the object passed in, where
429
    keys are the field names. Skips computed fields
430
    """
431
    data = {}
432
    obj = api.get_object(obj)
433
    for field_name, field in api.get_fields(obj).items():
434
        if skip_fields and field_name in skip_fields:
435
            continue
436
        if field.type == "computed":
437
            continue
438
        data[field_name] = field.get(obj)
439
    return data
440
441
442
def resolve_rejection_reasons(values):
443
    """Resolves the rejection reasons from the submitted values to the format
444
    supported by Sample's Rejection Reason field
445
    """
446
    rejection_reasons = values.get("RejectionReasons")
447
    if not rejection_reasons:
448
        return []
449
450
    # Predefined reasons selected?
451
    selected = rejection_reasons[0] or {}
452
    if selected.get("checkbox") == "on":
453
        selected = selected.get("multiselection") or []
454
    else:
455
        selected = []
456
457
    # Other reasons set?
458
    other = values.get("RejectionReasons.textfield")
459
    if other:
460
        other = other[0] or {}
461
        other = other.get("other", "")
462
    else:
463
        other = ""
464
465
    # If neither selected nor other reasons are set, return empty
466
    if any([selected, other]):
467
        return [{"selected": selected, "other": other}]
468
469
    return []
470
471
472
def do_rejection(sample, notify=None):
473
    """Rejects the sample and if succeeds, generates the rejection pdf and
474
    sends a notification email. If notify is None, the notification email will
475
    only be sent if the setting in Setup is enabled
476
    """
477
    sample_id = api.get_id(sample)
478
    if not sample.getRejectionReasons():
479
        logger.warn("Cannot reject {} w/o rejection reasons".format(sample_id))
480
        return
481
482
    success, msg = doActionFor(sample, "reject")
483
    if not success:
484
        logger.warn("Cannot reject the sample {}".format(sample_id))
485
        return
486
487
    # Generate a pdf with the rejection reasons
488
    pdf = get_rejection_pdf(sample)
489
490
    # Attach the PDF to the sample
491
    filename = "{}-rejected.pdf".format(sample_id)
492
    sample.createAttachment(pdf, filename=filename)
493
494
    # Do we need to send a notification email?
495
    if notify is None:
496
        setup = api.get_setup()
497
        notify = setup.getNotifyOnSampleRejection()
498
499
    if notify:
500
        # Compose and send the email
501
        mime_msg = get_rejection_mail(sample, pdf)
502
        if mime_msg:
503
            # Send the email
504
            send_email(mime_msg)
505
506
507
def get_rejection_pdf(sample):
508
    """Generates a pdf with sample rejection reasons
509
    """
510
    # Avoid circular dependencies
511
    from bika.lims.browser.analysisrequest.reject import \
512
        AnalysisRequestRejectPdfView
513
514
    # Render the html's rejection document
515
    tpl = AnalysisRequestRejectPdfView(sample, api.get_request())
516
    html = tpl.template()
517
    html = safe_unicode(html).encode("utf-8")
518
519
    # Generate the pdf
520
    return createPdf(htmlreport=html)
521
522
523
def get_rejection_mail(sample, rejection_pdf=None):
524
    """Generates an email to sample contacts with rejection reasons
525
    """
526
    # Get the reasons
527
    reasons = sample.getRejectionReasons()
528
    reasons = reasons and reasons[0] or {}
529
    reasons = reasons.get("selected", []) + [reasons.get("other")]
530
    reasons = filter(None, reasons)
531
    reasons = "<br/>- ".join(reasons)
532
533
    # Render the email body
534
    setup = api.get_setup()
535
    lab_address = setup.laboratory.getPrintAddress()
536
    email_body = Template(setup.getEmailBodySampleRejection())
537
    email_body = email_body.safe_substitute({
538
        "lab_address": "<br/>".join(lab_address),
539
        "reasons": reasons and "<br/>-{}".format(reasons) or "",
540
        "sample_id": api.get_id(sample),
541
        "sample_link": get_link(api.get_url(sample), api.get_id(sample))
542
    })
543
544
    def to_valid_email_address(contact):
545
        if not contact:
546
            return None
547
        address = contact.getEmailAddress()
548
        if not is_valid_email_address(address):
549
            return None
550
        return address
551
552
    # Get the recipients
553
    _to = [sample.getContact()] + sample.getCCContact()
554
    _to = map(to_valid_email_address, _to)
555
    _to = filter(None, _to)
556
557
    if not _to:
558
        # Cannot send an e-mail without recipient!
559
        logger.warn("No valid recipients for {}".format(api.get_id(sample)))
560
        return None
561
562
    lab = api.get_setup().laboratory
563
    attachments = rejection_pdf and [rejection_pdf] or []
564
565
    return compose_email(
566
        from_addr=lab.getEmailAddress(),
567
        to_addr=_to,
568
        subj=_("%s has been rejected") % api.get_id(sample),
569
        body=email_body,
570
        attachments=attachments)
571