bika.lims.utils.analysisrequest   F
last analyzed

Complexity

Total Complexity 81

Size/Duplication

Total Lines 646
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 81
eloc 349
dl 0
loc 646
rs 2
c 0
b 0
f 0

14 Functions

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

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