Passed
Push — master ( 43a600...99438d )
by Ramon
04:19
created

EmailView.__init__()   A

Complexity

Conditions 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 1
nop 3
1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2019 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import inspect
22
import itertools
23
import re
24
from collections import OrderedDict
25
from string import Template
26
27
import transaction
28
from bika.lims import _
29
from bika.lims import api
30
from bika.lims import logger
31
from bika.lims.api import mail as mailapi
32
from bika.lims.api.security import get_user
33
from bika.lims.api.security import get_user_id
34
from bika.lims.api.snapshot import take_snapshot
35
from bika.lims.decorators import returns_json
36
from bika.lims.utils import to_utf8
37
from DateTime import DateTime
38
from plone.memoize import view
39
from Products.CMFCore.WorkflowCore import WorkflowException
40
from Products.CMFPlone.utils import safe_unicode
41
from Products.Five.browser import BrowserView
42
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
43
from ZODB.POSException import POSKeyError
44
from zope.interface import implements
45
from zope.publisher.interfaces import IPublishTraverse
46
47
DEFAULT_MAX_EMAIL_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
        # list of requested subpaths
63
        self.traverse_subpath = []
64
        # toggle to allow email sending
65
        self.allow_send = True
66
67
    def __call__(self):
68
        # dispatch subpath request to `ajax_` methods
69
        if len(self.traverse_subpath) > 0:
70
            return self.handle_ajax_request()
71
72
        # handle standard request
73
        form = self.request.form
74
        send = form.get("send", False) and True or False
75
        cancel = form.get("cancel", False) and True or False
76
77
        if send and self.validate_email_form():
78
            logger.info("*** PUBLISH SAMPLES & SEND REPORTS ***")
79
            # 1. Publish all samples
80
            self.publish_samples()
81
            # 2. Notify all recipients
82
            self.form_action_send()
83
84
        elif cancel:
85
            logger.info("*** CANCEL EMAIL PUBLICATION ***")
86
            self.form_action_cancel()
87
88
        else:
89
            logger.info("*** RENDER EMAIL FORM ***")
90
            # validate email size
91
            self.validate_email_size()
92
            # validate email recipients
93
            self.validate_email_recipients()
94
95
        return self.template()
96
97
    def publishTraverse(self, request, name):
98
        """Called before __call__ for each path name
99
100
        Appends the path to the additional requested path after the view name
101
        to the internal `traverse_subpath` list
102
        """
103
        self.traverse_subpath.append(name)
104
        return self
105
106
    @returns_json
107
    def handle_ajax_request(self):
108
        """Handle requests ajax routes
109
        """
110
        # check if the method exists
111
        func_arg = self.traverse_subpath[0]
112
        func_name = "ajax_{}".format(func_arg)
113
        func = getattr(self, func_name, None)
114
115
        if func is None:
116
            return self.fail("Invalid function", status=400)
117
118
        # Additional provided path segments after the function name are handled
119
        # as positional arguments
120
        args = self.traverse_subpath[1:]
121
122
        # check mandatory arguments
123
        func_sig = inspect.getargspec(func)
124
        # positional arguments after `self` argument
125
        required_args = func_sig.args[1:]
126
127
        if len(args) < len(required_args):
128
            return self.fail("Wrong signature, please use '{}/{}'"
129
                             .format(func_arg, "/".join(required_args)), 400)
130
        return func(*args)
131
132
    def form_action_send(self):
133
        """Send form handler
134
        """
135
        # send email to the selected recipients and responsibles
136
        success = self.send_email(self.email_recipients_and_responsibles,
137
                                  self.email_subject,
138
                                  self.email_body,
139
                                  attachments=self.email_attachments)
140
141
        if success:
142
            # write email sendlog log to keep track of the email submission
143
            self.write_sendlog()
144
            message = _(u"Message sent to {}".format(
145
                ", ".join(self.email_recipients_and_responsibles)))
146
            self.add_status_message(message, "info")
147
        else:
148
            message = _("Failed to send Email(s)")
149
            self.add_status_message(message, "error")
150
151
        self.request.response.redirect(self.exit_url)
152
153
    def form_action_cancel(self):
154
        """Cancel form handler
155
        """
156
        self.add_status_message(_("Email cancelled"), "info")
157
        self.request.response.redirect(self.exit_url)
158
159
    def validate_email_form(self):
160
        """Validate if the email form is complete for send
161
162
        :returns: True if the validator passed, otherwise False
163
        """
164
        if not self.email_recipients_and_responsibles:
165
            message = _("No email recipients selected")
166
            self.add_status_message(message, "error")
167
        if not self.email_subject:
168
            message = _("Please add an email subject")
169
            self.add_status_message(message, "error")
170
        if not self.email_body:
171
            message = _("Please add an email text")
172
            self.add_status_message(message, "error")
173
        if not self.reports:
174
            message = _("No reports found")
175
            self.add_status_message(message, "error")
176
177
        if not all([self.email_recipients_and_responsibles,
178
                    self.email_subject,
179
                    self.email_body,
180
                    self.reports]):
181
            return False
182
        return True
183
184
    def validate_email_size(self):
185
        """Validate if the email size exceeded the max. allowed size
186
187
        :returns: True if the validator passed, otherwise False
188
        """
189
        if self.total_size > self.max_email_size:
190
            # don't allow to send oversized emails
191
            self.allow_send = False
192
            message = _("Total size of email exceeded {:.1f} MB ({:.2f} MB)"
193
                        .format(self.max_email_size / 1024,
194
                                self.total_size / 1024))
195
            self.add_status_message(message, "error")
196
            return False
197
        return True
198
199
    def validate_email_recipients(self):
200
        """Validate if the recipients are all valid
201
202
        :returns: True if the validator passed, otherwise False
203
        """
204
        # inform the user about invalid recipients
205
        if not all(map(lambda r: r.get("valid"), self.recipients_data)):
206
            message = _(
207
                "Not all contacts are equal for the selected Reports. "
208
                "Please manually select recipients for this email.")
209
            self.add_status_message(message, "warning")
210
            return False
211
        return True
212
213
    @property
214
    def portal(self):
215
        """Get the portal object
216
        """
217
        return api.get_portal()
218
219
    @property
220
    def laboratory(self):
221
        """Laboratory object from the LIMS setup
222
        """
223
        return api.get_setup().laboratory
224
225
    @property
226
    @view.memoize
227
    def reports(self):
228
        """Return the objects from the UIDs given in the request
229
        """
230
        # Create a mapping of source ARs for copy
231
        uids = self.request.form.get("uids", [])
232
        # handle 'uids' GET parameter coming from a redirect
233
        if isinstance(uids, basestring):
234
            uids = uids.split(",")
235
        uids = filter(api.is_uid, uids)
236
        unique_uids = OrderedDict().fromkeys(uids).keys()
237
        return map(self.get_object_by_uid, unique_uids)
238
239
    @property
240
    @view.memoize
241
    def attachments(self):
242
        """Return the objects from the UIDs given in the request
243
        """
244
        uids = self.request.form.get("attachment_uids", [])
245
        return map(self.get_object_by_uid, uids)
246
247
    @property
248
    def email_sender_address(self):
249
        """Sender email is either the lab email or portal email "from" address
250
        """
251
        lab_email = self.laboratory.getEmailAddress()
252
        portal_email = self.portal.email_from_address
253
        return lab_email or portal_email
254
255
    @property
256
    def email_sender_name(self):
257
        """Sender name is either the lab name or the portal email "from" name
258
        """
259
        lab_from_name = self.laboratory.getName()
260
        portal_from_name = self.portal.email_from_name
261
        return lab_from_name or portal_from_name
262
263
    @property
264
    def email_recipients_and_responsibles(self):
265
        """Returns a unified list of recipients and responsibles
266
        """
267
        return list(set(self.email_recipients + self.email_responsibles))
268
269
    @property
270
    def email_recipients(self):
271
        """Email addresses of the selected recipients
272
        """
273
        return map(safe_unicode, self.request.form.get("recipients", []))
274
275
    @property
276
    def email_responsibles(self):
277
        """Email addresses of the responsible persons
278
        """
279
        return map(safe_unicode, self.request.form.get("responsibles", []))
280
281
    @property
282
    def email_subject(self):
283
        """Email subject line to be used in the template
284
        """
285
        # request parameter has precedence
286
        subject = self.request.get("subject", None)
287
        if subject is not None:
288
            return subject
289
        subject = self.context.translate(_("Analysis Results for {}"))
290
        return subject.format(self.client_name)
291
292
    @property
293
    def email_body(self):
294
        """Email body text to be used in the template
295
        """
296
        # request parameter has precedence
297
        body = self.request.get("body", None)
298
        if body is not None:
299
            return body
300
        return self.context.translate(_(self.email_template(self)))
301
302
    @property
303
    def email_attachments(self):
304
        attachments = []
305
306
        # Convert report PDFs -> email attachments
307
        for report in self.reports:
308
            pdf = self.get_pdf(report)
309
            if pdf is None:
310
                logger.error("Skipping empty PDF for report {}"
311
                             .format(report.getId()))
312
                continue
313
            sample = report.getAnalysisRequest()
314
            filename = "{}.pdf".format(api.get_id(sample))
315
            filedata = pdf.data
316
            attachments.append(
317
                mailapi.to_email_attachment(filedata, filename))
318
319
        # Convert additional attachments
320
        for attachment in self.attachments:
321
            af = attachment.getAttachmentFile()
322
            filedata = af.data
323
            filename = af.filename
324
            attachments.append(
325
                mailapi.to_email_attachment(filedata, filename))
326
327
        return attachments
328
329
    @property
330
    def reports_data(self):
331
        """Returns a list of report data dictionaries
332
        """
333
        reports = self.reports
334
        return map(self.get_report_data, reports)
335
336
    @property
337
    def recipients_data(self):
338
        """Returns a list of recipients data dictionaries
339
        """
340
        reports = self.reports
341
        return self.get_recipients_data(reports)
342
343
    @property
344
    def responsibles_data(self):
345
        """Returns a list of responsibles data dictionaries
346
        """
347
        reports = self.reports
348
        return self.get_responsibles_data(reports)
349
350
    @property
351
    def client_name(self):
352
        """Returns the client name
353
        """
354
        return safe_unicode(self.context.Title())
355
356
    @property
357
    def exit_url(self):
358
        """Exit URL for redirect
359
        """
360
        return "{}/{}".format(
361
            api.get_url(self.context), "reports_listing")
362
363
    @property
364
    def total_size(self):
365
        """Total size of all report PDFs + additional attachments
366
        """
367
        reports = self.reports
368
        attachments = self.attachments
369
        return self.get_total_size(reports, attachments)
370
371
    @property
372
    def max_email_size(self):
373
        """Return the max. allowed email size in KB
374
        """
375
        # check first if a registry record exists
376
        max_email_size = api.get_registry_record(
377
            "senaite.core.max_email_size")
378
        if max_email_size is None:
379
            max_size = DEFAULT_MAX_EMAIL_SIZE
380
        if max_size < 0:
0 ignored issues
show
introduced by
The variable max_size does not seem to be defined in case max_email_size is None on line 378 is False. Are you sure this can never be the case?
Loading history...
381
            max_email_size = 0
382
        return max_size * 1024
383
384
    def make_sendlog_record(self, **kw):
385
        """Create a new sendlog record
386
        """
387
        user = get_user()
388
        actor = get_user_id()
389
        userprops = api.get_user_properties(user)
390
        actor_fullname = userprops.get("fullname", actor)
391
        email_send_date = DateTime()
392
        email_recipients = self.email_recipients
393
        email_responsibles = self.email_responsibles
394
        email_subject = self.email_subject
395
        email_body = self.render_email_template(self.email_body)
396
        email_attachments = map(api.get_uid, self.attachments)
397
398
        record = {
399
            "actor": actor,
400
            "actor_fullname": actor_fullname,
401
            "email_send_date": email_send_date,
402
            "email_recipients": email_recipients,
403
            "email_responsibles": email_responsibles,
404
            "email_subject": email_subject,
405
            "email_body": email_body,
406
            "email_attachments": email_attachments,
407
408
        }
409
        # keywords take precedence
410
        record.update(kw)
411
        return record
412
413
    def write_sendlog(self):
414
        """Write email sendlog
415
        """
416
        timestamp = DateTime()
417
418
        for report in self.reports:
419
            # get the current sendlog records
420
            records = report.getSendLog()
421
            # create a new record with the current data
422
            new_record = self.make_sendlog_record(email_send_date=timestamp)
423
            # set the new record to the existing records
424
            records.append(new_record)
425
            report.setSendLog(records)
426
            # reindex object to make changes visible in the snapshot
427
            report.reindexObject()
428
            # manually take a new snapshot
429
            take_snapshot(report)
430
431
    def publish_samples(self):
432
        """Publish all samples of the reports
433
        """
434
        samples = set()
435
436
        # collect primary + contained samples of the reports
437
        for report in self.reports:
438
            samples.add(report.getAnalysisRequest())
439
            samples.update(report.getContainedAnalysisRequests())
440
441
        # publish all samples + their partitions
442
        for sample in samples:
443
            self.publish(sample)
444
445
    def publish(self, sample):
446
        """Set status to prepublished/published/republished
447
        """
448
        wf = api.get_tool("portal_workflow")
449
        status = wf.getInfoFor(sample, "review_state")
450
        transitions = {"verified": "publish",
451
                       "published": "republish"}
452
        transition = transitions.get(status, "prepublish")
453
        logger.info("Transitioning sample {}: {} -> {}".format(
454
            api.get_id(sample), status, transition))
455
        try:
456
            # Manually update the view on the database to avoid conflict errors
457
            sample.getClient()._p_jar.sync()
458
            # Perform WF transition
459
            wf.doActionFor(sample, transition)
460
            # Commit the changes
461
            transaction.commit()
462
        except WorkflowException as e:
463
            logger.error(e)
464
465
    def render_email_template(self, template):
466
        """Return the rendered email template
467
468
        This method interpolates the $recipients variable with the selected
469
        recipients from the email form.
470
471
        :params template: Email body text
472
        :returns: Rendered email template
473
        """
474
475
        recipients = self.email_recipients_and_responsibles
476
        template_context = {
477
            "recipients": "\n".join(recipients)
478
        }
479
480
        email_template = Template(safe_unicode(template)).safe_substitute(
481
            **template_context)
482
483
        return email_template
484
485
    def send_email(self, recipients, subject, body, attachments=None):
486
        """Prepare and send email to the recipients
487
488
        :param recipients: a list of email or name,email strings
489
        :param subject: the email subject
490
        :param body: the email body
491
        :param attachments: list of email attachments
492
        :returns: True if all emails were sent, else False
493
        """
494
        email_body = self.render_email_template(body)
495
496
        success = []
497
        # Send one email per recipient
498
        for recipient in recipients:
499
            # N.B. we use just the email here to prevent this Postfix Error:
500
            # Recipient address rejected: User unknown in local recipient table
501
            pair = mailapi.parse_email_address(recipient)
502
            to_address = pair[1]
503
            mime_msg = mailapi.compose_email(self.email_sender_address,
504
                                             to_address,
505
                                             subject,
506
                                             email_body,
507
                                             attachments=attachments)
508
            sent = mailapi.send_email(mime_msg)
509
            if not sent:
510
                msg = _("Could not send email to {0} ({1})").format(pair[0],
511
                                                                    pair[1])
512
                self.add_status_message(msg, "warning")
513
                logger.error(msg)
514
            success.append(sent)
515
516
        if not all(success):
517
            return False
518
        return True
519
520
    def add_status_message(self, message, level="info"):
521
        """Set a portal status message
522
        """
523
        return self.context.plone_utils.addPortalMessage(message, level)
524
525
    def get_report_data(self, report):
526
        """Report data to be used in the template
527
        """
528
        sample = report.getAnalysisRequest()
529
        analyses = sample.getAnalyses(full_objects=True)
530
        # merge together sample + analyses attachments
531
        attachments = itertools.chain(
532
            sample.getAttachment(),
533
            *map(lambda an: an.getAttachment(), analyses))
534
        attachments_data = map(self.get_attachment_data, attachments)
535
        pdf = self.get_pdf(report)
536
        filesize = "{} Kb".format(self.get_filesize(pdf))
537
        filename = "{}.pdf".format(sample.getId())
538
539
        return {
540
            "sample": sample,
541
            "attachments": attachments_data,
542
            "pdf": pdf,
543
            "obj": report,
544
            "uid": api.get_uid(report),
545
            "filesize": filesize,
546
            "filename": filename,
547
        }
548
549
    def get_attachment_data(self, attachment):
550
        """Attachments data to be used in the template
551
        """
552
        f = attachment.getAttachmentFile()
553
        attachment_type = attachment.getAttachmentType()
554
        attachment_keys = attachment.getAttachmentKeys()
555
        filename = f.filename
556
        filesize = self.get_filesize(f)
557
        mimetype = f.getContentType()
558
        report_option = attachment.getReportOption()
559
560
        return {
561
            "obj": attachment,
562
            "attachment_type": attachment_type,
563
            "attachment_keys": attachment_keys,
564
            "file": f,
565
            "uid": api.get_uid(attachment),
566
            "filesize": filesize,
567
            "filename": filename,
568
            "mimetype": mimetype,
569
            "report_option": report_option,
570
        }
571
572
    def get_recipients_data(self, reports):
573
        """Recipients data to be used in the template
574
        """
575
        if not reports:
576
            return []
577
578
        recipients = []
579
        recipient_names = []
580
581
        for num, report in enumerate(reports):
582
            sample = report.getAnalysisRequest()
583
            # recipient names of this report
584
            report_recipient_names = []
585
            for recipient in self.get_recipients(sample):
586
                name = recipient.get("Fullname")
587
                email = recipient.get("EmailAddress")
588
                address = mailapi.to_email_address(email, name=name)
589
                record = {
590
                    "name": name,
591
                    "email": email,
592
                    "address": address,
593
                    "valid": True,
594
                }
595
                if record not in recipients:
596
                    recipients.append(record)
597
                # remember the name of the recipient for this report
598
                report_recipient_names.append(name)
599
            recipient_names.append(report_recipient_names)
600
601
        # recipient names, which all of the reports have in common
602
        common_names = set(recipient_names[0]).intersection(*recipient_names)
603
        # mark recipients not in common
604
        for recipient in recipients:
605
            if recipient.get("name") not in common_names:
606
                recipient["valid"] = False
607
        return recipients
608
609
    def get_responsibles_data(self, reports):
610
        """Responsibles data to be used in the template
611
        """
612
        if not reports:
613
            return []
614
615
        recipients = []
616
        recipient_names = []
617
618
        for num, report in enumerate(reports):
619
            # get the linked AR of this ARReport
620
            ar = report.getAnalysisRequest()
621
622
            # recipient names of this report
623
            report_recipient_names = []
624
            responsibles = ar.getResponsible()
625
            for manager_id in responsibles.get("ids", []):
626
                responsible = responsibles["dict"][manager_id]
627
                name = responsible.get("name")
628
                email = responsible.get("email")
629
                address = mailapi.to_email_address(email, name=name)
630
                record = {
631
                    "name": name,
632
                    "email": email,
633
                    "address": address,
634
                    "valid": True,
635
                }
636
                if record not in recipients:
637
                    recipients.append(record)
638
                # remember the name of the recipient for this report
639
                report_recipient_names.append(name)
640
            recipient_names.append(report_recipient_names)
641
642
        # recipient names, which all of the reports have in common
643
        common_names = set(recipient_names[0]).intersection(*recipient_names)
644
        # mark recipients not in common
645
        for recipient in recipients:
646
            if recipient.get("name") not in common_names:
647
                recipient["valid"] = False
648
649
        return recipients
650
651
    def get_total_size(self, *files):
652
        """Calculate the total size of the given files
653
        """
654
655
        # Recursive unpack an eventual list of lists
656
        def iterate(item):
657
            if isinstance(item, (list, tuple)):
658
                for i in item:
659
                    for ii in iterate(i):
660
                        yield ii
661
            else:
662
                yield item
663
664
        # Calculate the total size of the given objects starting with an
665
        # initial size of 0
666
        return reduce(lambda x, y: x + y,
667
                      map(self.get_filesize, iterate(files)), 0)
668
669
    def get_object_by_uid(self, uid):
670
        """Get the object by UID
671
        """
672
        logger.debug("get_object_by_uid::UID={}".format(uid))
673
        obj = api.get_object_by_uid(uid, None)
674
        if obj is None:
675
            logger.warn("!! No object found for UID #{} !!")
676
        return obj
677
678
    def get_filesize(self, f):
679
        """Return the filesize of the PDF as a float
680
        """
681
        try:
682
            filesize = float(f.get_size())
683
            return float("%.2f" % (filesize / 1024))
684
        except (POSKeyError, TypeError, AttributeError):
685
            return 0.0
686
687
    def get_pdf(self, obj):
688
        """Get the report PDF
689
        """
690
        try:
691
            return obj.getPdf()
692
        except (POSKeyError, TypeError):
693
            return None
694
695 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...
696
        """Return the AR recipients in the same format like the AR Report
697
        expects in the records field `Recipients`
698
        """
699
        plone_utils = api.get_tool("plone_utils")
700
701
        def is_email(email):
702
            if not plone_utils.validateSingleEmailAddress(email):
703
                return False
704
            return True
705
706
        def recipient_from_contact(contact):
707
            if not contact:
708
                return None
709
            email = contact.getEmailAddress()
710
            return {
711
                "UID": api.get_uid(contact),
712
                "Username": contact.getUsername(),
713
                "Fullname": to_utf8(contact.Title()),
714
                "EmailAddress": email,
715
            }
716
717
        def recipient_from_email(email):
718
            if not is_email(email):
719
                return None
720
            return {
721
                "UID": "",
722
                "Username": "",
723
                "Fullname": email,
724
                "EmailAddress": email,
725
            }
726
727
        # Primary Contacts
728
        to = filter(None, [recipient_from_contact(ar.getContact())])
729
        # CC Contacts
730
        cc = filter(None, map(recipient_from_contact, ar.getCCContact()))
731
        # CC Emails
732
        cc_emails = ar.getCCEmails(as_list=True)
733
        cc_emails = filter(None, map(recipient_from_email, cc_emails))
734
735
        return to + cc + cc_emails
736
737
    def ajax_recalculate_size(self):
738
        """Recalculate the total size of the selected attachments
739
        """
740
        reports = self.reports
741
        attachments = self.attachments
742
        total_size = self.get_total_size(reports, attachments)
743
744
        return {
745
            "files": len(reports) + len(attachments),
746
            "size": "%.2f" % total_size,
747
            "limit": self.max_email_size,
748
            "limit_exceeded": total_size > self.max_email_size,
749
        }
750
751
    def fail(self, message, status=500, **kw):
752
        """Set a JSON error object and a status to the response
753
        """
754
        self.request.response.setStatus(status)
755
        result = {"success": False, "errors": message, "status": status}
756
        result.update(kw)
757
        return result
758