Passed
Push — master ( b124da...78fa88 )
by Jordi
04:16
created

EmailView.get_recipients_data()   B

Complexity

Conditions 7

Size

Total Lines 35
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 24
dl 0
loc 35
rs 7.904
c 0
b 0
f 0
cc 7
nop 2
1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE
4
#
5
# Copyright 2018 by it's authors.
6
7
import inspect
8
import mimetypes
9
import socket
10
from collections import OrderedDict
11
from email import encoders
12
from email.header import Header
13
from email.mime.base import MIMEBase
14
from email.mime.multipart import MIMEMultipart
15
from email.mime.text import MIMEText
16
from email.Utils import formataddr
17
from smtplib import SMTPException
18
from string import Template
19
20
from bika.lims import logger
21
from bika.lims.utils import to_utf8
22
from Products.CMFCore.WorkflowCore import WorkflowException
23
from Products.CMFPlone.utils import safe_unicode
24
from Products.Five.browser import BrowserView
25
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
26
from bika.lims import api
27
from bika.lims import _
28
from bika.lims.decorators import returns_json
29
from ZODB.POSException import POSKeyError
30
from zope.interface import implements
31
from zope.publisher.interfaces import IPublishTraverse
32
33
EMAIL_MAX_SIZE = 15
34
35
36
class EmailView(BrowserView):
37
    """Email Attachments View
38
    """
39
    implements(IPublishTraverse)
40
41
    template = ViewPageTemplateFile("templates/email.pt")
42
    email_template = ViewPageTemplateFile("templates/email_template.pt")
43
44
    def __init__(self, context, request):
45
        super(EmailView, self).__init__(context, request)
46
        # disable Plone's editable border
47
        request.set("disable_border", True)
48
        # remember context/request
49
        self.context = context
50
        self.request = request
51
        self.url = self.context.absolute_url()
52
        # the URL to redirect on cancel or after send
53
        self.exit_url = "{}/{}".format(self.url, "reports_listing")
54
        # we need to transform the title to unicode, so that we can use it for
55
        self.client_name = safe_unicode(self.context.Title())
56
        self.email_body = self.context.translate(_(self.email_template(self)))
57
        # string interpolation later
58
        # N.B. We need to translate the raw string before interpolation
59
        subject = self.context.translate(_("Analysis Results for {}"))
60
        self.email_subject = subject.format(self.client_name)
61
        self.allow_send = True
62
        self.traverse_subpath = []
63
64
    def __call__(self):
65
        # handle subpath request
66
        if len(self.traverse_subpath) > 0:
67
            return self.handle_ajax_request()
68
        # handle standard request
69
        return self.handle_http_request()
70
71
    def publishTraverse(self, request, name):
72
        """Called before __call__ for each path name
73
        """
74
        self.traverse_subpath.append(name)
75
        return self
76
77
    def fail(self, message, status=500, **kw):
78
        """Set a JSON error object and a status to the response
79
        """
80
        self.request.response.setStatus(status)
81
        result = {"success": False, "errors": message, "status": status}
82
        result.update(kw)
83
        return result
84
85
    @returns_json
86
    def handle_ajax_request(self):
87
        """Handle requests ajax routes
88
        """
89
        # check if the method exists
90
        func_arg = self.traverse_subpath[0]
91
        func_name = "ajax_{}".format(func_arg)
92
        func = getattr(self, func_name, None)
93
94
        if func is None:
95
            return self.fail("Invalid function", status=400)
96
97
        # Additional provided path segments after the function name are handled
98
        # as positional arguments
99
        args = self.traverse_subpath[1:]
100
101
        # check mandatory arguments
102
        func_sig = inspect.getargspec(func)
103
        # positional arguments after `self` argument
104
        required_args = func_sig.args[1:]
105
106
        if len(args) < len(required_args):
107
            return self.fail("Wrong signature, please use '{}/{}'"
108
                             .format(func_arg, "/".join(required_args)), 400)
109
        return func(*args)
110
111
    def handle_http_request(self):
112
        request = self.request
113
        form = request.form
114
115
        submitted = form.get("submitted", False)
116
        send = form.get("send", False)
117
        cancel = form.get("cancel", False)
118
119
        if submitted and send:
120
            logger.info("*** SENDING EMAIL ***")
121
122
            # Parse used defined values from the request form
123
            recipients = form.get("recipients", [])
124
            responsibles = form.get("responsibles", [])
125
            subject = form.get("subject")
126
            body = form.get("body")
127
            reports = self.get_reports()
128
129
            # Merge recipiens and responsibles
130
            recipients = set(recipients + responsibles)
131
132
            # sanity checks
133
            if not recipients:
134
                message = _("No email recipients selected")
135
                self.add_status_message(message, "error")
136
            if not subject:
137
                message = _("Please add an email subject")
138
                self.add_status_message(message, "error")
139
            if not body:
140
                message = _("Please add an email text")
141
                self.add_status_message(message, "error")
142
            if not reports:
143
                message = _("No attachments")
144
                self.add_status_message(message, "error")
145
146
            success = False
147
            if all([recipients, subject, body, reports]):
148
                attachments = []
149
150
                # report pdfs
151
                for report in reports:
152
                    pdf = self.get_pdf(report)
153
                    if pdf is None:
154
                        logger.error("Skipping empty PDF for report {}"
155
                                     .format(report.getId()))
156
                        continue
157
                    ar = report.getAnalysisRequest()
158
                    filename = "{}.pdf".format(ar.getId())
159
                    filedata = pdf.data
160
                    attachments.append(
161
                        self.to_email_attachment(filename, filedata))
162
163
                # additional attachments
164
                for attachment in self.get_attachments():
165
                    af = attachment.getAttachmentFile()
166
                    filedata = af.data
167
                    filename = af.filename
168
                    attachments.append(
169
                        self.to_email_attachment(filename, filedata))
170
171
                success = self.send_email(
172
                    recipients, subject, body, attachments=attachments)
173
174
            if success:
175
                # selected name, email pairs which received the email
176
                pairs = map(self.parse_email, recipients)
177
                send_to_names = map(lambda p: p[0], pairs)
178
179
                # set recipients to the reports
180
                for report in reports:
181
                    ar = report.getAnalysisRequest()
182
                    # publish the AR
183
                    self.publish(ar)
184
185
                    # Publish all linked ARs of this report
186
                    # N.B. `ContainedAnalysisRequests` is an extended field
187
                    field = report.getField("ContainedAnalysisRequests")
188
                    contained_ars = field.get(report) or []
189
                    for obj in contained_ars:
190
                        self.publish(obj)
191
192
                    # add new recipients to the AR Report
193
                    new_recipients = filter(
194
                        lambda r: r.get("Fullname") in send_to_names,
0 ignored issues
show
introduced by
The variable send_to_names does not seem to be defined for all execution paths.
Loading history...
195
                        self.get_recipients(ar))
196
                    self.set_report_recipients(report, new_recipients)
197
198
                message = _(u"Message sent to {}"
199
                            .format(", ".join(send_to_names)))
200
                self.add_status_message(message, "info")
201
                return request.response.redirect(self.exit_url)
202
            else:
203
                message = _("Failed to send Email(s)")
204
                self.add_status_message(message, "error")
205
206
        if submitted and cancel:
207
            logger.info("*** EMAIL CANCELLED ***")
208
            message = _("Email cancelled")
209
            self.add_status_message(message, "info")
210
            return request.response.redirect(self.exit_url)
211
212
        # get the selected ARReport objects
213
        reports = self.get_reports()
214
        attachments = self.get_attachments()
215
216
        # calculate the total size of all PDFs
217
        self.total_size = self.get_total_size(reports, attachments)
218
        if self.total_size > self.max_email_size:
219
            # don't allow to send oversized emails
220
            self.allow_send = False
221
            message = _("Total size of email exceeded {:.1f} MB ({:.2f} MB)"
222
                        .format(self.max_email_size / 1024,
223
                                self.total_size / 1024))
224
            self.add_status_message(message, "error")
225
226
        # prepare the data for the template
227
        self.reports = map(self.get_report_data, reports)
228
        self.recipients = self.get_recipients_data(reports)
229
        self.responsibles = self.get_responsibles_data(reports)
230
231
        # inform the user about invalid recipients
232
        if not all(map(lambda r: r.get("valid"), self.recipients)):
233
            message = _(
234
                "Not all contacts are equal for the selected Reports. "
235
                "Please manually select recipients for this email.")
236
            self.add_status_message(message, "warning")
237
238
        return self.template()
239
240
    def set_report_recipients(self, report, recipients):
241
        """Set recipients to the reports w/o overwriting the old ones
242
243
        :param reports: list of ARReports
244
        :param recipients: list of name,email strings
245
        """
246
        to_set = report.getRecipients()
247
        for recipient in recipients:
248
            if recipient not in to_set:
249
                to_set.append(recipient)
250
        report.setRecipients(to_set)
251
252
    def publish(self, ar):
253
        """Set status to prepublished/published/republished
254
        """
255
        wf = api.get_tool("portal_workflow")
256
        status = wf.getInfoFor(ar, "review_state")
257
        transitions = {"verified": "publish",
258
                       "published": "republish"}
259
        transition = transitions.get(status, "prepublish")
260
        logger.info("AR Transition: {} -> {}".format(status, transition))
261
        try:
262
            wf.doActionFor(ar, transition)
263
            return True
264
        except WorkflowException as e:
265
            logger.debug(e)
266
            return False
267
268
    def parse_email(self, email):
269
        """parse an email to an unicode name, email tuple
270
        """
271
        splitted = safe_unicode(email).rsplit(",", 1)
272
        if len(splitted) == 1:
273
            return (False, splitted[0])
274
        elif len(splitted) == 2:
275
            return (splitted[0], splitted[1])
276
        else:
277
            raise ValueError("Could not parse email '{}'".format(email))
278
279
    def to_email_attachment(self, filename, filedata, **kw):
280
        """Create a new MIME Attachment
281
282
        The Content-Type: header is build from the maintype and subtype of the
283
        guessed filename mimetype. Additional parameters for this header are
284
        taken from the keyword arguments.
285
        """
286
        maintype = "application"
287
        subtype = "octet-stream"
288
289
        mime_type = mimetypes.guess_type(filename)[0]
290
        if mime_type is not None:
291
            maintype, subtype = mime_type.split("/")
292
293
        attachment = MIMEBase(maintype, subtype, **kw)
294
        attachment.set_payload(filedata)
295
        encoders.encode_base64(attachment)
296
        attachment.add_header("Content-Disposition",
297
                              "attachment; filename=%s" % filename)
298
        return attachment
299
300
    def send_email(self, recipients, subject, body, attachments=None):
301
        """Prepare and send email to the recipients
302
303
        :param recipients: a list of email or name,email strings
304
        :param subject: the email subject
305
        :param body: the email body
306
        :param attachments: list of email attachments
307
        :returns: True if all emails were sent, else false
308
        """
309
310
        recipient_pairs = map(self.parse_email, recipients)
311
        template_context = {
312
            "recipients": "\n".join(
313
                map(lambda p: formataddr(p), recipient_pairs))
314
        }
315
316
        body_template = Template(safe_unicode(body)).safe_substitute(
317
            **template_context)
318
319
        _preamble = "This is a multi-part message in MIME format.\n"
320
        _from = formataddr((self.email_from_name, self.email_from_address))
321
        _subject = Header(s=safe_unicode(subject), charset="utf8")
322
        _body = MIMEText(body_template, _subtype="plain", _charset="utf8")
323
324
        # Create the enclosing message
325
        mime_msg = MIMEMultipart()
326
        mime_msg.preamble = _preamble
327
        mime_msg["Subject"] = _subject
328
        mime_msg["From"] = _from
329
        mime_msg.attach(_body)
330
331
        # Attach attachments
332
        for attachment in attachments:
333
            mime_msg.attach(attachment)
334
335
        success = []
336
        # Send one email per recipient
337
        for pair in recipient_pairs:
338
            # N.B.: Headers are added additive, so we need to remove any
339
            #       existing "To" headers
340
            # No KeyError is raised if the key does not exist.
341
            # https://docs.python.org/2/library/email.message.html#email.message.Message.__delitem__
342
            del mime_msg["To"]
343
344
            # N.B. we use just the email here to prevent this Postfix Error:
345
            # Recipient address rejected: User unknown in local recipient table
346
            mime_msg["To"] = pair[1]
347
            msg_string = mime_msg.as_string()
348
            sent = self.send(msg_string)
349
            if not sent:
350
                logger.error("Could not send email to {}".format(pair))
351
            success.append(sent)
352
353
        if not all(success):
354
            return False
355
        return True
356
357
    def send(self, msg_string, immediate=True):
358
        """Send the email via the MailHost tool
359
        """
360
        try:
361
            mailhost = api.get_tool("MailHost")
362
            mailhost.send(msg_string, immediate=immediate)
363
        except SMTPException as e:
364
            logger.error(e)
365
            return False
366
        except socket.error as e:
367
            logger.error(e)
368
            return False
369
        return True
370
371
    def add_status_message(self, message, level="info"):
372
        """Set a portal status message
373
        """
374
        return self.context.plone_utils.addPortalMessage(message, level)
375
376
    def get_report_data(self, report):
377
        """Report data to be used in the template
378
        """
379
        ar = report.getAnalysisRequest()
380
        attachments = map(self.get_attachment_data, ar.getAttachment())
381
        pdf = self.get_pdf(report)
382
        filesize = "{} Kb".format(self.get_filesize(pdf))
383
        filename = "{}.pdf".format(ar.getId())
384
385
        return {
386
            "ar": ar,
387
            "attachments": attachments,
388
            "pdf": pdf,
389
            "obj": report,
390
            "uid": api.get_uid(report),
391
            "filesize": filesize,
392
            "filename": filename,
393
        }
394
395
    def get_attachment_data(self, attachment):
396
        """Attachments data
397
        """
398
        f = attachment.getAttachmentFile()
399
        attachment_type = attachment.getAttachmentType()
400
        attachment_keys = attachment.getAttachmentKeys()
401
        filename = f.filename
402
        filesize = self.get_filesize(f)
403
        mimetype = f.getContentType()
404
        report_option = attachment.getReportOption()
405
406
        return {
407
            "obj": attachment,
408
            "attachment_type": attachment_type,
409
            "attachment_keys": attachment_keys,
410
            "file": f,
411
            "uid": api.get_uid(attachment),
412
            "filesize": filesize,
413
            "filename": filename,
414
            "mimetype": mimetype,
415
            "report_option": report_option,
416
        }
417
418
    def get_recipients_data(self, reports):
419
        """Recipients data to be used in the template
420
        """
421
        if not reports:
422
            return []
423
424
        recipients = []
425
        recipient_names = []
426
427
        for num, report in enumerate(reports):
428
            # get the linked AR of this ARReport
429
            ar = report.getAnalysisRequest()
430
            # recipient names of this report
431
            report_recipient_names = []
432
            for recipient in self.get_recipients(ar):
433
                name = recipient.get("Fullname")
434
                email = recipient.get("EmailAddress")
435
                record = {
436
                    "name": name,
437
                    "email": email,
438
                    "valid": True,
439
                }
440
                if record not in recipients:
441
                    recipients.append(record)
442
                # remember the name of the recipient for this report
443
                report_recipient_names.append(name)
444
            recipient_names.append(report_recipient_names)
445
446
        # recipient names, which all of the reports have in common
447
        common_names = set(recipient_names[0]).intersection(*recipient_names)
448
        # mark recipients not in common
449
        for recipient in recipients:
450
            if recipient.get("name") not in common_names:
451
                recipient["valid"] = False
452
        return recipients
453
454
    def get_responsibles_data(self, reports):
455
        """Responsibles data to be used in the template
456
        """
457
        if not reports:
458
            return []
459
460
        recipients = []
461
        recipient_names = []
462
463
        for num, report in enumerate(reports):
464
            # get the linked AR of this ARReport
465
            ar = report.getAnalysisRequest()
466
467
            # recipient names of this report
468
            report_recipient_names = []
469
            responsibles = ar.getResponsible()
470
            for manager_id in responsibles.get("ids", []):
471
                responsible = responsibles["dict"][manager_id]
472
                name = responsible.get("name")
473
                email = responsible.get("email")
474
                record = {
475
                    "name": name,
476
                    "email": email,
477
                    "valid": True,
478
                }
479
                if record not in recipients:
480
                    recipients.append(record)
481
                # remember the name of the recipient for this report
482
                report_recipient_names.append(name)
483
            recipient_names.append(report_recipient_names)
484
485
        # recipient names, which all of the reports have in common
486
        common_names = set(recipient_names[0]).intersection(*recipient_names)
487
        # mark recipients not in common
488
        for recipient in recipients:
489
            if recipient.get("name") not in common_names:
490
                recipient["valid"] = False
491
492
        return recipients
493
494
    @property
495
    def portal(self):
496
        return api.get_portal()
497
498
    @property
499
    def laboratory(self):
500
        return api.get_setup().laboratory
501
502
    @property
503
    def email_from_address(self):
504
        """Portal email
505
        """
506
        lab_email = self.laboratory.getEmailAddress()
507
        portal_email = self.portal.email_from_address
508
        return lab_email or portal_email
509
510
    @property
511
    def email_from_name(self):
512
        """Portal email name
513
        """
514
        lab_from_name = self.laboratory.getName()
515
        portal_from_name = self.portal.email_from_name
516
        return lab_from_name or portal_from_name
517
518
    def get_total_size(self, *files):
519
        """Calculate the total size of the given files
520
        """
521
522
        # Recursive unpack an eventual list of lists
523
        def iterate(item):
524
            if isinstance(item, (list, tuple)):
525
                for i in item:
526
                    for ii in iterate(i):
527
                        yield ii
528
            else:
529
                yield item
530
531
        # Calculate the total size of the given objects starting with an
532
        # initial size of 0
533
        return reduce(lambda x, y: x + y,
0 ignored issues
show
Comprehensibility Best Practice introduced by
Undefined variable 'reduce'
Loading history...
534
                      map(self.get_filesize, iterate(files)), 0)
535
536
    @property
537
    def max_email_size(self):
538
        """Return the max. allowed email size in KB
539
        """
540
        # TODO: Refactor to customizable setup option
541
        max_size = EMAIL_MAX_SIZE
542
        if max_size < 0:
543
            return 0.0
544
        return max_size * 1024
545
546
    def get_reports(self):
547
        """Return the objects from the UIDs given in the request
548
        """
549
        # Create a mapping of source ARs for copy
550
        uids = self.request.form.get("uids", [])
551
        # handle 'uids' GET parameter coming from a redirect
552
        if isinstance(uids, basestring):
553
            uids = uids.split(",")
554
        uids = filter(api.is_uid, uids)
555
        unique_uids = OrderedDict().fromkeys(uids).keys()
556
        return map(self.get_object_by_uid, unique_uids)
557
558
    def get_attachments(self):
559
        """Return the objects from the UIDs given in the request
560
        """
561
        # Create a mapping of source ARs for copy
562
        uids = self.request.form.get("attachment_uids", [])
563
        return map(self.get_object_by_uid, uids)
564
565
    def get_object_by_uid(self, uid):
566
        """Get the object by UID
567
        """
568
        logger.debug("get_object_by_uid::UID={}".format(uid))
569
        obj = api.get_object_by_uid(uid, None)
570
        if obj is None:
571
            logger.warn("!! No object found for UID #{} !!")
572
        return obj
573
574
    def get_filesize(self, f):
575
        """Return the filesize of the PDF as a float
576
        """
577
        try:
578
            filesize = float(f.get_size())
579
            return float("%.2f" % (filesize / 1024))
580
        except (POSKeyError, TypeError, AttributeError):
581
            return 0.0
582
583
    def get_pdf(self, obj):
584
        """Get the report PDF
585
        """
586
        try:
587
            return obj.getPdf()
588
        except (POSKeyError, TypeError):
589
            return None
590
591 View Code Duplication
    def get_recipients(self, ar):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
592
        """Return the AR recipients in the same format like the AR Report
593
        expects in the records field `Recipients`
594
        """
595
        plone_utils = api.get_tool("plone_utils")
596
597
        def is_email(email):
598
            if not plone_utils.validateSingleEmailAddress(email):
599
                return False
600
            return True
601
602
        def recipient_from_contact(contact):
603
            if not contact:
604
                return None
605
            email = contact.getEmailAddress()
606
            return {
607
                "UID": api.get_uid(contact),
608
                "Username": contact.getUsername(),
609
                "Fullname": to_utf8(contact.Title()),
610
                "EmailAddress": email,
611
            }
612
613
        def recipient_from_email(email):
614
            if not is_email(email):
615
                return None
616
            return {
617
                "UID": "",
618
                "Username": "",
619
                "Fullname": email,
620
                "EmailAddress": email,
621
            }
622
623
        # Primary Contacts
624
        to = filter(None, [recipient_from_contact(ar.getContact())])
625
        # CC Contacts
626
        cc = filter(None, map(recipient_from_contact, ar.getCCContact()))
627
        # CC Emails
628
        cc_emails = map(lambda x: x.strip(), ar.getCCEmails().split(","))
629
        cc_emails = filter(None, map(recipient_from_email, cc_emails))
630
631
        return to + cc + cc_emails
632
633
    def ajax_recalculate_size(self):
634
        """Recalculate the total size of the selected attachments
635
        """
636
        reports = self.get_reports()
637
        attachments = self.get_attachments()
638
        total_size = self.get_total_size(reports, attachments)
639
640
        return {
641
            "files": len(reports) + len(attachments),
642
            "size": "%.2f" % total_size,
643
            "limit": self.max_email_size,
644
            "limit_exceeded": total_size > self.max_email_size,
645
        }
646