Passed
Push — master ( d79aa2...26548a )
by Jordi
11:12
created

EmailView.responsibles_data()   A

Complexity

Conditions 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 1
nop 1
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
from collections import OrderedDict
24
from string import Template
25
26
import transaction
27
from bika.lims import _
28
from bika.lims import api
29
from bika.lims import logger
30
from bika.lims.api import mail as mailapi
31
from bika.lims.api.security import get_user
32
from bika.lims.api.security import get_user_id
33
from bika.lims.api.snapshot import take_snapshot
34
from bika.lims.decorators import returns_json
35
from bika.lims.utils import to_utf8
36
from DateTime import DateTime
37
from plone.memoize import view
38
from Products.CMFCore.WorkflowCore import WorkflowException
39
from Products.CMFPlone.utils import safe_unicode
40
from Products.Five.browser import BrowserView
41
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
42
from ZODB.POSException import POSKeyError
43
from zope.interface import implements
44
from zope.publisher.interfaces import IPublishTraverse
45
46
DEFAULT_MAX_EMAIL_SIZE = 15
47
48
49
class EmailView(BrowserView):
50
    """Email Attachments View
51
    """
52
    implements(IPublishTraverse)
53
54
    template = ViewPageTemplateFile("templates/email.pt")
55
    email_template = ViewPageTemplateFile("templates/email_template.pt")
56
57
    def __init__(self, context, request):
58
        super(EmailView, self).__init__(context, request)
59
        # disable Plone's editable border
60
        request.set("disable_border", True)
61
        # list of requested subpaths
62
        self.traverse_subpath = []
63
        # toggle to allow email sending
64
        self.allow_send = True
65
66
    def __call__(self):
67
        # dispatch subpath request to `ajax_` methods
68
        if len(self.traverse_subpath) > 0:
69
            return self.handle_ajax_request()
70
71
        # handle standard request
72
        form = self.request.form
73
        send = form.get("send", False) and True or False
74
        cancel = form.get("cancel", False) and True or False
75
76
        if send and self.validate_email_form():
77
            logger.info("*** PUBLISH SAMPLES & SEND REPORTS ***")
78
            # 1. Publish all samples
79
            self.publish_samples()
80
            # 2. Notify all recipients
81
            self.form_action_send()
82
83
        elif cancel:
84
            logger.info("*** CANCEL EMAIL PUBLICATION ***")
85
            self.form_action_cancel()
86
87
        else:
88
            logger.info("*** RENDER EMAIL FORM ***")
89
            # validate email size
90
            self.validate_email_size()
91
            # validate email recipients
92
            self.validate_email_recipients()
93
94
        return self.template()
95
96
    def publishTraverse(self, request, name):
97
        """Called before __call__ for each path name
98
99
        Appends the path to the additional requested path after the view name
100
        to the internal `traverse_subpath` list
101
        """
102
        self.traverse_subpath.append(name)
103
        return self
104
105
    @returns_json
106
    def handle_ajax_request(self):
107
        """Handle requests ajax routes
108
        """
109
        # check if the method exists
110
        func_arg = self.traverse_subpath[0]
111
        func_name = "ajax_{}".format(func_arg)
112
        func = getattr(self, func_name, None)
113
114
        if func is None:
115
            return self.fail("Invalid function", status=400)
116
117
        # Additional provided path segments after the function name are handled
118
        # as positional arguments
119
        args = self.traverse_subpath[1:]
120
121
        # check mandatory arguments
122
        func_sig = inspect.getargspec(func)
123
        # positional arguments after `self` argument
124
        required_args = func_sig.args[1:]
125
126
        if len(args) < len(required_args):
127
            return self.fail("Wrong signature, please use '{}/{}'"
128
                             .format(func_arg, "/".join(required_args)), 400)
129
        return func(*args)
130
131
    def form_action_send(self):
132
        """Send form handler
133
        """
134
        # send email to the selected recipients and responsibles
135
        success = self.send_email(self.email_recipients_and_responsibles,
136
                                  self.email_subject,
137
                                  self.email_body,
138
                                  attachments=self.email_attachments)
139
140
        if success:
141
            # write email sendlog log to keep track of the email submission
142
            self.write_sendlog()
143
            message = _(u"Message sent to {}".format(
144
                ", ".join(self.email_recipients_and_responsibles)))
145
            self.add_status_message(message, "info")
146
        else:
147
            message = _("Failed to send Email(s)")
148
            self.add_status_message(message, "error")
149
150
        self.request.response.redirect(self.exit_url)
151
152
    def form_action_cancel(self):
153
        """Cancel form handler
154
        """
155
        self.add_status_message(_("Email cancelled"), "info")
156
        self.request.response.redirect(self.exit_url)
157
158
    def validate_email_form(self):
159
        """Validate if the email form is complete for send
160
161
        :returns: True if the validator passed, otherwise False
162
        """
163
        if not self.email_recipients_and_responsibles:
164
            message = _("No email recipients selected")
165
            self.add_status_message(message, "error")
166
        if not self.email_subject:
167
            message = _("Please add an email subject")
168
            self.add_status_message(message, "error")
169
        if not self.email_body:
170
            message = _("Please add an email text")
171
            self.add_status_message(message, "error")
172
        if not self.reports:
173
            message = _("No reports found")
174
            self.add_status_message(message, "error")
175
176
        if not all([self.email_recipients_and_responsibles,
177
                    self.email_subject,
178
                    self.email_body,
179
                    self.reports]):
180
            return False
181
        return True
182
183
    def validate_email_size(self):
184
        """Validate if the email size exceeded the max. allowed size
185
186
        :returns: True if the validator passed, otherwise False
187
        """
188
        if self.total_size > self.max_email_size:
189
            # don't allow to send oversized emails
190
            self.allow_send = False
191
            message = _("Total size of email exceeded {:.1f} MB ({:.2f} MB)"
192
                        .format(self.max_email_size / 1024,
193
                                self.total_size / 1024))
194
            self.add_status_message(message, "error")
195
            return False
196
        return True
197
198
    def validate_email_recipients(self):
199
        """Validate if the recipients are all valid
200
201
        :returns: True if the validator passed, otherwise False
202
        """
203
        # inform the user about invalid recipients
204
        if not all(map(lambda r: r.get("valid"), self.recipients_data)):
205
            message = _(
206
                "Not all contacts are equal for the selected Reports. "
207
                "Please manually select recipients for this email.")
208
            self.add_status_message(message, "warning")
209
            return False
210
        return True
211
212
    @property
213
    def portal(self):
214
        """Get the portal object
215
        """
216
        return api.get_portal()
217
218
    @property
219
    def laboratory(self):
220
        """Laboratory object from the LIMS setup
221
        """
222
        return api.get_setup().laboratory
223
224
    @property
225
    @view.memoize
226
    def reports(self):
227
        """Return the objects from the UIDs given in the request
228
        """
229
        # Create a mapping of source ARs for copy
230
        uids = self.request.form.get("uids", [])
231
        # handle 'uids' GET parameter coming from a redirect
232
        if isinstance(uids, basestring):
233
            uids = uids.split(",")
234
        uids = filter(api.is_uid, uids)
235
        unique_uids = OrderedDict().fromkeys(uids).keys()
236
        return map(self.get_object_by_uid, unique_uids)
237
238
    @property
239
    @view.memoize
240
    def attachments(self):
241
        """Return the objects from the UIDs given in the request
242
        """
243
        uids = self.request.form.get("attachment_uids", [])
244
        return map(self.get_object_by_uid, uids)
245
246
    @property
247
    def email_sender_address(self):
248
        """Sender email is either the lab email or portal email "from" address
249
        """
250
        lab_email = self.laboratory.getEmailAddress()
251
        portal_email = self.portal.email_from_address
252
        return lab_email or portal_email
253
254
    @property
255
    def email_sender_name(self):
256
        """Sender name is either the lab name or the portal email "from" name
257
        """
258
        lab_from_name = self.laboratory.getName()
259
        portal_from_name = self.portal.email_from_name
260
        return lab_from_name or portal_from_name
261
262
    @property
263
    def email_recipients_and_responsibles(self):
264
        """Returns a unified list of recipients and responsibles
265
        """
266
        return list(set(self.email_recipients + self.email_responsibles))
267
268
    @property
269
    def email_recipients(self):
270
        """Email addresses of the selected recipients
271
        """
272
        return map(safe_unicode, self.request.form.get("recipients", []))
273
274
    @property
275
    def email_responsibles(self):
276
        """Email addresses of the responsible persons
277
        """
278
        return map(safe_unicode, self.request.form.get("responsibles", []))
279
280
    @property
281
    def email_subject(self):
282
        """Email subject line to be used in the template
283
        """
284
        # request parameter has precedence
285
        subject = self.request.get("subject", None)
286
        if subject is not None:
287
            return subject
288
        subject = self.context.translate(_("Analysis Results for {}"))
289
        return subject.format(self.client_name)
290
291
    @property
292
    def email_body(self):
293
        """Email body text to be used in the template
294
        """
295
        # request parameter has precedence
296
        body = self.request.get("body", None)
297
        if body is not None:
298
            return body
299
        return self.context.translate(_(self.email_template(self)))
300
301
    @property
302
    def email_attachments(self):
303
        attachments = []
304
305
        # Convert report PDFs -> email attachments
306
        for report in self.reports:
307
            pdf = self.get_pdf(report)
308
            if pdf is None:
309
                logger.error("Skipping empty PDF for report {}"
310
                             .format(report.getId()))
311
                continue
312
            sample = report.getAnalysisRequest()
313
            filename = "{}.pdf".format(api.get_id(sample))
314
            filedata = pdf.data
315
            attachments.append(
316
                mailapi.to_email_attachment(filedata, filename))
317
318
        # Convert additional attachments
319
        for attachment in self.attachments:
320
            af = attachment.getAttachmentFile()
321
            filedata = af.data
322
            filename = af.filename
323
            attachments.append(
324
                mailapi.to_email_attachment(filedata, filename))
325
326
        return attachments
327
328
    @property
329
    def reports_data(self):
330
        """Returns a list of report data dictionaries
331
        """
332
        reports = self.reports
333
        return map(self.get_report_data, reports)
334
335
    @property
336
    def recipients_data(self):
337
        """Returns a list of recipients data dictionaries
338
        """
339
        reports = self.reports
340
        return self.get_recipients_data(reports)
341
342
    @property
343
    def responsibles_data(self):
344
        """Returns a list of responsibles data dictionaries
345
        """
346
        reports = self.reports
347
        return self.get_responsibles_data(reports)
348
349
    @property
350
    def client_name(self):
351
        """Returns the client name
352
        """
353
        return safe_unicode(self.context.Title())
354
355
    @property
356
    def exit_url(self):
357
        """Exit URL for redirect
358
        """
359
        return "{}/{}".format(
360
            api.get_url(self.context), "reports_listing")
361
362
    @property
363
    def total_size(self):
364
        """Total size of all report PDFs + additional attachments
365
        """
366
        reports = self.reports
367
        attachments = self.attachments
368
        return self.get_total_size(reports, attachments)
369
370
    @property
371
    def max_email_size(self):
372
        """Return the max. allowed email size in KB
373
        """
374
        # check first if a registry record exists
375
        max_email_size = api.get_registry_record(
376
            "senaite.core.max_email_size")
377
        if max_email_size is None:
378
            max_size = DEFAULT_MAX_EMAIL_SIZE
379
        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 377 is False. Are you sure this can never be the case?
Loading history...
380
            max_email_size = 0
381
        return max_size * 1024
382
383
    def make_sendlog_record(self, **kw):
384
        """Create a new sendlog record
385
        """
386
        user = get_user()
387
        actor = get_user_id()
388
        userprops = api.get_user_properties(user)
389
        actor_fullname = userprops.get("fullname", actor)
390
        email_send_date = DateTime()
391
        email_recipients = self.email_recipients
392
        email_responsibles = self.email_responsibles
393
        email_subject = self.email_subject
394
        email_body = self.render_email_template(self.email_body)
395
        email_attachments = map(api.get_uid, self.attachments)
396
397
        record = {
398
            "actor": actor,
399
            "actor_fullname": actor_fullname,
400
            "email_send_date": email_send_date,
401
            "email_recipients": email_recipients,
402
            "email_responsibles": email_responsibles,
403
            "email_subject": email_subject,
404
            "email_body": email_body,
405
            "email_attachments": email_attachments,
406
407
        }
408
        # keywords take precedence
409
        record.update(kw)
410
        return record
411
412
    def write_sendlog(self):
413
        """Write email sendlog
414
        """
415
        timestamp = DateTime()
416
417
        for report in self.reports:
418
            # get the current sendlog records
419
            records = report.getSendLog()
420
            # create a new record with the current data
421
            new_record = self.make_sendlog_record(email_send_date=timestamp)
422
            # set the new record to the existing records
423
            records.append(new_record)
424
            report.setSendLog(records)
425
            # reindex object to make changes visible in the snapshot
426
            report.reindexObject()
427
            # manually take a new snapshot
428
            take_snapshot(report)
429
430
    def publish_samples(self):
431
        """Publish all samples of the reports
432
        """
433
        reports = self.reports
434
        for report in reports:
435
            # publish the primary sample
436
            primary_sample = report.getAnalysisRequest()
437
            self.publish(primary_sample)
438
            # publish the contained samples
439
            contained_samples = report.getContainedAnalysisRequests()
440
            for sample in contained_samples:
441
                # skip the primary sample
442
                if sample == primary_sample:
443
                    continue
444
                self.publish(sample)
445
446
    def publish(self, sample):
447
        """Set status to prepublished/published/republished
448
        """
449
        wf = api.get_tool("portal_workflow")
450
        status = wf.getInfoFor(sample, "review_state")
451
        transitions = {"verified": "publish",
452
                       "published": "republish"}
453
        transition = transitions.get(status, "prepublish")
454
        logger.info("Transitioning sample {}: {} -> {}".format(
455
            api.get_id(sample), status, transition))
456
        try:
457
            # Manually update the view on the database to avoid conflict errors
458
            sample.getClient()._p_jar.sync()
459
            # Perform WF transition
460
            wf.doActionFor(sample, transition)
461
            # Commit the changes
462
            transaction.commit()
463
            return True
464
        except WorkflowException as e:
465
            logger.error(e)
466
            return False
467
468
    def render_email_template(self, template):
469
        """Return the rendered email template
470
471
        This method interpolates the $recipients variable with the selected
472
        recipients from the email form.
473
474
        :params template: Email body text
475
        :returns: Rendered email template
476
        """
477
478
        recipients = self.email_recipients_and_responsibles
479
        template_context = {
480
            "recipients": "\n".join(recipients)
481
        }
482
483
        email_template = Template(safe_unicode(template)).safe_substitute(
484
            **template_context)
485
486
        return email_template
487
488
    def send_email(self, recipients, subject, body, attachments=None):
489
        """Prepare and send email to the recipients
490
491
        :param recipients: a list of email or name,email strings
492
        :param subject: the email subject
493
        :param body: the email body
494
        :param attachments: list of email attachments
495
        :returns: True if all emails were sent, else False
496
        """
497
        email_body = self.render_email_template(body)
498
499
        success = []
500
        # Send one email per recipient
501
        for recipient in recipients:
502
            # N.B. we use just the email here to prevent this Postfix Error:
503
            # Recipient address rejected: User unknown in local recipient table
504
            pair = mailapi.parse_email_address(recipient)
505
            to_address = pair[1]
506
            mime_msg = mailapi.compose_email(self.email_sender_address,
507
                                             to_address,
508
                                             subject,
509
                                             email_body,
510
                                             attachments=attachments)
511
            sent = mailapi.send_email(mime_msg)
512
            if not sent:
513
                logger.error("Could not send email to {}".format(pair))
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 = map(lambda x: x.strip(), ar.getCCEmails().split(","))
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