Passed
Push — 2.x ( 6121d6...a5fbfb )
by Jordi
08:10
created

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

Complexity

Conditions 6

Size

Total Lines 30
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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