Passed
Push — master ( b60410...062e8c )
by Jordi
05:05
created

ilview.EmailView.handle_http_request()   F

Complexity

Conditions 21

Size

Total Lines 128
Code Lines 90

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 90
dl 0
loc 128
rs 0
c 0
b 0
f 0
cc 21
nop 1

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

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