bika.lims.utils.analysisrequest   F
last analyzed

Complexity

Total Complexity 80

Size/Duplication

Total Lines 645
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 80
eloc 348
dl 0
loc 645
rs 2
c 0
b 0
f 0

14 Functions

Rating   Name   Duplication   Size   Complexity  
B do_rejection() 0 34 6
A get_hidden_service_uids() 0 9 4
A apply_hidden_services() 0 27 4
A get_rejection_pdf() 0 9 1
C to_service_uid() 0 34 9
A resolve_rejection_reasons() 0 25 4
A fields_to_dict() 0 14 5
B create_partition() 0 84 6
D create_retest() 0 76 12
B receive_sample() 0 33 6
B to_service_uids() 0 35 6
A get_rejection_mail() 0 46 4
C create_analysisrequest() 0 122 11
A get_rejection_email_recipients() 0 12 2

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