changeset 8288b688cfeb in trytond:default
details: https://hg.tryton.org/trytond?cmd=changeset;node=8288b688cfeb
description:
        Send client email via server

        issue9449
        review292001002
diffstat:

 CHANGELOG                                  |    2 +
 trytond/ir/__init__.py                     |    5 +
 trytond/ir/action.py                       |   38 +--
 trytond/ir/email.xml                       |   91 +++++
 trytond/ir/email_.py                       |  478 +++++++++++++++++++++++++++++
 trytond/ir/message.xml                     |    8 +-
 trytond/ir/tryton.cfg                      |    1 +
 trytond/ir/view/action_report_form.xml     |    2 -
 trytond/ir/view/action_wizard_form.xml     |    2 -
 trytond/ir/view/email_form.xml             |   19 +
 trytond/ir/view/email_list.xml             |   13 +
 trytond/ir/view/email_template_form.xml    |   20 +
 trytond/ir/view/email_template_list.xml    |    7 +
 trytond/model/modelview.py                 |    5 +
 trytond/res/ir.xml                         |   15 +
 trytond/res/view/user_form.xml             |    2 +-
 trytond/res/view/user_form_preferences.xml |    2 +-
 trytond/tests/test_ir.py                   |  118 +++++++
 18 files changed, 785 insertions(+), 43 deletions(-)

diffs (1032 lines):

diff -r c6f20e2e714c -r 8288b688cfeb CHANGELOG
--- a/CHANGELOG Wed Sep 16 23:26:09 2020 +0200
+++ b/CHANGELOG Thu Sep 17 17:18:02 2020 +0200
@@ -1,3 +1,5 @@
+* Add e-mail template
+* Send e-mail on behalf of user
 * Register mixins to generic Report class
 * Cache in memory the report template instances
 * Support Genshi's MsgDirective in report
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/__init__.py
--- a/trytond/ir/__init__.py    Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/ir/__init__.py    Thu Sep 17 17:18:02 2020 +0200
@@ -21,6 +21,7 @@
 from . import queue
 from . import calendar_
 from . import message
+from . import email_
 from . import routes
 
 __all__ = ['register', 'routes']
@@ -94,6 +95,10 @@
         calendar_.Month,
         calendar_.Day,
         message.Message,
+        email_.Email,
+        email_.EmailAddress,
+        email_.EmailTemplate,
+        email_.EmailTemplate_Report,
         module='ir', type_='model')
     Pool.register(
         translation.TranslationSet,
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/action.py
--- a/trytond/ir/action.py      Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/ir/action.py      Thu Sep 17 17:18:02 2020 +0200
@@ -107,7 +107,7 @@
             columns = []
         columns += ['id', 'name', 'type', 'icon.rec_name']
         if type_ == 'ir.action.report':
-            columns += ['report_name', 'direct_print', 'email']
+            columns += ['report_name', 'direct_print']
         elif type_ == 'ir.action.act_window':
             columns += [
                 'views', 'domains', 'res_model', 'limit',
@@ -115,7 +115,7 @@
                 'pyson_domain', 'pyson_context', 'pyson_order',
                 'pyson_search_value']
         elif type_ == 'ir.action.wizard':
-            columns += ['wiz_name', 'window', 'email']
+            columns += ['wiz_name', 'window']
         elif type_ == 'ir.action.url':
             columns += ['url']
         return Action.read(action_ids, columns)
@@ -540,10 +540,6 @@
         string='Extension', help='Leave empty for the same as template, '
         'see LibreOffice documentation for compatible format.')
     module = fields.Char('Module', readonly=True, select=True)
-    email = fields.Char('Email',
-        help='Python dictonary where keys define "to" "cc" "subject"\n'
-        "Example: {'to': '[email protected]', 'cc': '[email protected]'}")
-    pyson_email = fields.Function(fields.Char('PySON Email'), 'get_pyson')
     _template_cache = MemoryCache('ir.action.report.template', context=False)
 
     @classmethod
@@ -596,31 +592,6 @@
     def default_module():
         return Transaction().context.get('module') or ''
 
-    @classmethod
-    def validate(cls, reports):
-        super(ActionReport, cls).validate(reports)
-        cls.check_email(reports)
-
-    @classmethod
-    def check_email(cls, reports):
-        "Check email"
-        for report in reports:
-            if report.email:
-                try:
-                    value = PYSONDecoder().decode(report.email)
-                except Exception:
-                    value = None
-                if isinstance(value, dict):
-                    inkeys = set(value)
-                    if not inkeys <= EMAIL_REFKEYS:
-                        raise EmailError(
-                            gettext('ir.msg_report_invalid_email',
-                                name=report.rec_name))
-                else:
-                    raise EmailError(
-                        gettext('ir.msg_report_invalid_email',
-                            name=report.rec_name))
-
     def get_is_custom(self, name):
         return bool(self.report_content_custom)
 
@@ -670,9 +641,7 @@
     def get_pyson(cls, reports, name):
         pysons = {}
         field = name[6:]
-        defaults = {
-            'email': '{}',
-            }
+        defaults = {}
         for report in reports:
             pysons[report.id] = (getattr(report, field)
                 or defaults.get(field, 'null'))
@@ -1051,7 +1020,6 @@
     action = fields.Many2One('ir.action', 'Action', required=True,
             ondelete='CASCADE')
     model = fields.Char('Model')
-    email = fields.Char('Email')
     window = fields.Boolean('Window', help='Run wizard in a new window.')
 
     @staticmethod
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/email.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/ir/email.xml      Thu Sep 17 17:18:02 2020 +0200
@@ -0,0 +1,91 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tryton>
+    <data>
+        <record model="ir.ui.view" id="email_view_form">
+            <field name="model">ir.email</field>
+            <field name="type">form</field>
+            <field name="name">email_form</field>
+        </record>
+        <record model="ir.ui.view" id="email_view_list">
+            <field name="model">ir.email</field>
+            <field name="type">tree</field>
+            <field name="name">email_list</field>
+        </record>
+
+        <record model="ir.action.act_window" id="act_email_form">
+            <field name="name">E-mails</field>
+            <field name="res_model">ir.email</field>
+        </record>
+        <record model="ir.action.act_window.view" id="act_email_form_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" ref="email_view_list"/>
+            <field name="act_window" ref="act_email_form"/>
+        </record>
+        <record model="ir.action.act_window.view" id="act_email_form_view2">
+            <field name="sequence" eval="20"/>
+            <field name="view" ref="email_view_form"/>
+            <field name="act_window" ref="act_email_form"/>
+        </record>
+        <menuitem parent="menu_models" action="act_email_form" 
id="menu_email_form"/>
+
+        <record model="ir.model.access" id="access_email">
+            <field name="model" search="[('model', '=', 'ir.email')]"/>
+            <field name="perm_create" eval="True"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+
+        <record model="ir.action.act_window" id="act_email_form_relate">
+            <field name="name">E-mails</field>
+            <field name="res_model">ir.email</field>
+            <field
+                name="domain"
+                eval="[If(Eval('active_ids', []) == [Eval('active_id')], 
('resource', '=', [Eval('active_model'), Eval('active_id')]), ('resource.id', 
'in', Eval('active_ids'), Eval('active_model')))]"
+                pyson="1"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_email_form_relate_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" ref="email_view_list"/>
+            <field name="act_window" ref="act_email_form_relate"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_email_form_relate_view2">
+            <field name="sequence" eval="20"/>
+            <field name="view" ref="email_view_form"/>
+            <field name="act_window" ref="act_email_form_relate"/>
+        </record>
+        <record model="ir.action.keyword" id="act_email_form_relate_keyword1">
+            <field name="keyword">form_relate</field>
+            <field name="action" ref="act_email_form_relate"/>
+        </record>
+
+        <record model="ir.ui.view" id="email_template_view_form">
+            <field name="model">ir.email.template</field>
+            <field name="type">form</field>
+            <field name="name">email_template_form</field>
+        </record>
+        <record model="ir.ui.view" id="email_template_view_list">
+            <field name="model">ir.email.template</field>
+            <field name="type">tree</field>
+            <field name="name">email_template_list</field>
+        </record>
+
+        <record model="ir.action.act_window" id="act_email_template_form">
+            <field name="name">E-mail Templates</field>
+            <field name="res_model">ir.email.template</field>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_email_template_form_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" ref="email_template_view_list"/>
+            <field name="act_window" ref="act_email_template_form"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_email_template_form_view2">
+            <field name="sequence" eval="20"/>
+            <field name="view" ref="email_template_view_form"/>
+            <field name="act_window" ref="act_email_template_form"/>
+        </record>
+        <menuitem parent="menu_action" action="act_email_template_form" 
id="menu_email_template_form"/>
+    </data>
+</tryton>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/email_.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/ir/email_.py      Thu Sep 17 17:18:02 2020 +0200
@@ -0,0 +1,478 @@
+# This file is part of Tryton.  The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+import heapq
+import mimetypes
+import re
+from email.encoders import encode_base64
+from email.header import Header
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.nonmultipart import MIMENonMultipart
+from email.mime.text import MIMEText
+from email.utils import formataddr, getaddresses
+
+try:
+    import html2text
+except ImportError:
+    html2text = None
+from genshi.template import TextTemplate
+
+from trytond.i18n import gettext
+from trytond.config import config
+from trytond.model import ModelSQL, ModelView, fields
+from trytond.model.exceptions import ValidationError, AccessError
+from trytond.pool import Pool
+from trytond.pyson import Eval
+from trytond.rpc import RPC
+from trytond.sendmail import sendmail_transactional, SMTPDataManager
+from trytond.tools import escape_wildcard
+from trytond.tools.string_ import StringMatcher
+from trytond.transaction import Transaction
+
+from .resource import ResourceAccessMixin
+
+HTML_EMAIL = """<!DOCTYPE html>
+<html>
+<head><title>%(subject)s</title></head>
+<body>%(body)s<br/>
+<hr style="width: 2em; text-align: start; display: inline-block"/><br/>
+%(signature)s</body>
+</html>"""
+specialsre = re.compile(r'[][\\()<>@,:;".]')
+escapesre = re.compile(r'[\\"]')
+
+
+class EmailTemplateError(ValidationError):
+    pass
+
+
+def _get_emails(value):
+    "Return list of email from the comma separated list"
+    return [e for n, e in getaddresses([value]) if e]
+
+
+def _formataddr(pair):
+    "Format address without encoding"
+    name, address = pair
+    address.encode('ascii')
+    if name:
+        quotes = ''
+        if specialsre.search(name):
+            quotes = '"'
+        name = escapesre.sub(r'\\\g<0>', name)
+        return '%s%s%s <%s>' % (quotes, name, quotes, address)
+    return address
+
+
+class Email(ResourceAccessMixin, ModelSQL, ModelView):
+    "Email"
+    __name__ = 'ir.email'
+
+    user = fields.Function(fields.Char("User"), 'get_user')
+    at = fields.Function(fields.DateTime("At"), 'get_at')
+    recipients = fields.Char("Recipients")
+    recipients_secondary = fields.Char("Secondary Recipients")
+    recipients_hidden = fields.Char("Hidden Recipients")
+    addresses = fields.One2Many('ir.email.address', 'email', "Addresses")
+    subject = fields.Char("Subject")
+    body = fields.Char("Body")
+
+    @classmethod
+    def __setup__(cls):
+        super().__setup__()
+        cls._order.insert(0, ('create_date', 'DESC'))
+        cls.__rpc__.update({
+                'send': RPC(readonly=False, result=int),
+                'complete': RPC(),
+                })
+        del cls.__rpc__['create']
+
+    def get_user(self, name):
+        return self.create_uid.rec_name
+
+    def get_at(self, name):
+        return self.create_date.replace(microsecond=0)
+
+    @classmethod
+    def send(cls, to='', cc='', bcc='', subject='', body='',
+            attachments=None, record=None, reports=None):
+        pool = Pool()
+        User = pool.get('res.user')
+        ActionReport = pool.get('ir.action.report')
+        Attachment = pool.get('ir.attachment')
+        transaction = Transaction()
+        user = User(transaction.user)
+
+        Model = pool.get(record[0])
+        record = Model(record[1])
+
+        body_html = HTML_EMAIL % {
+            'subject': subject,
+            'body': body,
+            'signature': user.signature or '',
+            }
+        content = MIMEMultipart('alternative')
+        if html2text:
+            body_text = HTML_EMAIL % {
+                'subject': subject,
+                'body': body,
+                'signature': '',
+                }
+            converter = html2text.HTML2Text()
+            body_text = converter.handle(body_text)
+            if user.signature:
+                body_text += '\n-- \n' + converter.handle(user.signature)
+            part = MIMEText(body_text, 'plain', _charset='utf-8')
+            content.attach(part)
+        part = MIMEText(body_html, 'html', _charset='utf-8')
+        content.attach(part)
+        if reports or attachments:
+            msg = MIMEMultipart('mixed')
+            msg.attach(content)
+            if attachments is None:
+                attachments = []
+            else:
+                attachments = list(attachments)
+
+            for report_id in (reports or []):
+                report = ActionReport(report_id)
+                Report = pool.get(report.report_name, type='report')
+                ext, content, _, title = Report.execute(
+                    [record.id], {
+                        'action_id': report.id,
+                        })
+                name = '%s.%s' % (title, ext)
+                if isinstance(content, str):
+                    content = content.encode('utf-8')
+                attachments.append((name, content))
+
+            for name, data in attachments:
+                mimetype, _ = mimetypes.guess_type(name)
+                if mimetype:
+                    attachment = MIMENonMultipart(*mimetype.split('/'))
+                    attachment.set_payload(data)
+                    encode_base64(attachment)
+                else:
+                    attachment = MIMEApplication(data)
+                attachment.add_header(
+                    'Content-Disposition', 'attachment',
+                    filename=('utf-8', '', name))
+                msg.attach(attachment)
+        else:
+            msg = content
+        msg['From'] = from_ = config.get('email', 'from')
+        if user.email:
+            if user.name:
+                user_email = formataddr((user.name, user.email))
+            else:
+                user_email = user.email
+            msg['Behalf-Of'] = user_email
+            msg['Reply-To'] = user_email
+        msg['To'] = ', '.join(formataddr(a) for a in getaddresses([to]))
+        msg['Cc'] = ', '.join(formataddr(a) for a in getaddresses([cc]))
+        msg['Subject'] = Header(subject, 'utf-8')
+
+        to_addrs = list(filter(None, map(
+                    str.strip,
+                    _get_emails(to) + _get_emails(cc) + _get_emails(bcc))))
+        sendmail_transactional(
+            from_, to_addrs, msg, datamanager=SMTPDataManager(strict=True))
+
+        email = cls(
+            recipients=to,
+            recipients_secondary=cc,
+            recipients_hidden=bcc,
+            addresses=[{'address': a} for a in to_addrs],
+            subject=subject,
+            body=body,
+            resource=record)
+        email.save()
+        with Transaction().set_context(_check_access=False):
+            attachments_ = []
+            for name, data in attachments:
+                attachments_.append(
+                    Attachment(resource=email, name=name, data=data))
+            Attachment.save(attachments_)
+        return email
+
+    @classmethod
+    def complete(cls, text, limit):
+        limit = int(limit)
+        if not limit > 0:
+            raise ValueError('limit must be > 0: %r' % (limit,))
+        emails = getaddresses([text])
+        if not emails:
+            return []
+        name, email = map(str.strip, emails[-1])
+        if not name and not email:
+            return []
+        s = StringMatcher()
+        s.set_seq2(_formataddr((name, email)))
+
+        def generate(name, email):
+            for name, email in cls._match(name, email):
+                address = _formataddr((name, email))
+                s.set_seq1(address)
+                yield (
+                    s.ratio(), address,
+                    ', '.join(map(_formataddr, emails[:-1] + [(name, email)])))
+        return heapq.nlargest(limit, generate(name, email))
+
+    @classmethod
+    def _match(cls, name, email):
+        pool = Pool()
+        User = pool.get('res.user')
+        domain = ['OR']
+        for field in ['name', 'login', 'email']:
+            for value in [name, email]:
+                if value and len(value) >= 3:
+                    domain.append(
+                        (field, 'ilike', '%' + escape_wildcard(value) + '%'))
+        for user in User.search([
+                    ('email', '!=', ''),
+                    domain,
+                    ], order=[]):
+            yield user.name, user.email
+
+
+class EmailAddress(ModelSQL):
+    "Email Address"
+    __name__ = 'ir.email.address'
+
+    email = fields.Many2One(
+        'ir.email', "E-mail", required=True, ondelete='CASCADE', select=True)
+    address = fields.Char("Address", required=True, select=True)
+
+
+class EmailTemplate(ModelSQL, ModelView):
+    "Email Template"
+    __name__ = 'ir.email.template'
+
+    model = fields.Many2One('ir.model', "Model", required=True)
+    name = fields.Char("Name", required=True, translate=True)
+    recipients = fields.Many2One(
+        'ir.model.field', "Recipients",
+        domain=[
+            ('model', '=', Eval('model')),
+            ],
+        depends=['model'],
+        help="The field that contains the recipient(s).")
+    recipients_secondary = fields.Many2One(
+        'ir.model.field', "Secondary Recipients",
+        domain=[
+            ('model', '=', Eval('model')),
+            ],
+        depends=['model'],
+        help="The field that contains the secondary recipient(s).")
+    recipients_hidden = fields.Many2One(
+        'ir.model.field', "Hidden Recipients",
+        domain=[
+            ('model', '=', Eval('model')),
+            ],
+        depends=['model'],
+        help="The field that contains the secondary recipient(s).")
+    subject = fields.Char("Subject", translate=True)
+    body = fields.Text("Body", translate=True)
+    reports = fields.Many2Many(
+        'ir.email.template-ir.action.report', 'template', 'report',
+        "Reports",
+        domain=[
+            ('model', '=', Eval('model_name')),
+            ],
+        depends=['model_name'])
+
+    model_name = fields.Function(
+        fields.Char("Model Name"), 'on_change_with_model_name')
+
+    @classmethod
+    def __setup__(cls):
+        super().__setup__()
+        for field in [
+                'recipients',
+                'recipients_secondary',
+                'recipients_hidden',
+                ]:
+            field = getattr(cls, field)
+            field.domain.append(['OR',
+                    ('relation', 'in', cls.email_models()),
+                    [
+                        ('model.model', 'in', cls.email_models()),
+                        ('name', '=', 'id'),
+                        ],
+                    ])
+        cls.__rpc__.update({
+                'get': RPC(instantiate=0),
+                'get_default': RPC(),
+                })
+
+    @fields.depends('model')
+    def on_change_with_model_name(self, name=None):
+        if self.model:
+            return self.model.model
+
+    @classmethod
+    def validate(cls, templates):
+        super().validate(templates)
+        for template in templates:
+            template.check_subject()
+            template.check_body()
+
+    def check_subject(self):
+        if not self.subject:
+            return
+        try:
+            TextTemplate(self.subject)
+        except Exception as exception:
+            raise EmailTemplateError(
+                gettext('ir.msg_email_template_invalid_subject',
+                    template=self.rec_name,
+                    exception=exception)) from exception
+
+    def check_body(self):
+        if not self.body:
+            return
+        try:
+            TextTemplate(self.body)
+        except Exception as exception:
+            raise EmailTemplateError(
+                gettext('ir.msg_email_template_invalid_body',
+                    template=self.rec_name,
+                    exception=exception)) from exception
+
+    def get(self, record):
+        pool = Pool()
+        Model = pool.get(self.model.model)
+        record = Model(int(record))
+
+        values = {}
+        for field, key in [
+                ('recipients', 'to'),
+                ('recipients_secondary', 'cc'),
+                ('recipients_hidden', 'bcc'),
+                ]:
+            field = getattr(self, field)
+            if not field:
+                continue
+            try:
+                if field.name == 'id':
+                    value = record
+                else:
+                    value = getattr(record, field.name, None)
+                if value:
+                    values[key] = self.get_addresses(value)
+            except AccessError:
+                continue
+
+        if self.subject:
+            try:
+                values['subject'] = (TextTemplate(self.subject)
+                    .generate(record=record)
+                    .render())
+            except AccessError:
+                pass
+        if self.body:
+            try:
+                values['body'] = (TextTemplate(self.body)
+                    .generate(record=record)
+                    .render())
+            except AccessError:
+                pass
+        if self.reports:
+            values['reports'] = [r.id for r in self.reports]
+        return values
+
+    @classmethod
+    def _get_default_exclude(cls, record):
+        return ['create_uid', 'write_uid']
+
+    @classmethod
+    def get_default(cls, model, record):
+        pool = Pool()
+        Field = pool.get('ir.model.field')
+        Model = pool.get(model)
+        record = Model(int(record))
+        values = {}
+
+        fields = Field.search([
+                ('model.model', '=', model),
+                ('name', 'not in', cls._get_default_exclude(record)),
+                ['OR',
+                    ('relation', 'in', cls.email_models()),
+                    [
+                        ('model.model', 'in', cls.email_models()),
+                        ('name', '=', 'id'),
+                        ],
+                    ],
+                ])
+        values['to'] = addresses = []
+        for field in fields:
+            try:
+                if field.name == 'id':
+                    value = record
+                else:
+                    value = getattr(record, field.name)
+                addresses += cls.get_addresses(value)
+            except AccessError:
+                pass
+
+        try:
+            values['subject'] = '%s: %s' % (
+                Model.__names__()['model'], record.rec_name)
+        except AccessError:
+            pass
+        return values
+
+    @classmethod
+    def email_models(cls):
+        return ['res.user']
+
+    @classmethod
+    def get_addresses(cls, value):
+        if isinstance(value, (list, tuple)):
+            addresses = (cls._get_address(v) for v in value)
+        else:
+            addresses = [cls._get_address(value)]
+        return [
+            _formataddr((name, email))
+            for name, email in filter(None, addresses)
+            if email]
+
+    @classmethod
+    def _get_address(cls, record):
+        pool = Pool()
+        User = pool.get('res.user')
+        if isinstance(record, User) and record.email:
+            return (record.name, record.email)
+
+    @classmethod
+    def get_languages(cls, value):
+        pool = Pool()
+        Configuration = pool.get('ir.configuration')
+        Lang = pool.get('ir.lang')
+        if isinstance(value, (list, tuple)):
+            languagues = {cls._get_language(v) for v in value}
+        else:
+            languagues = {cls._get_language(value)}
+        languagues = list(filter(None, languagues))
+        if not languagues:
+            return Lang.search([
+                    ('code', '=', Configuration.get_language()),
+                    ], limit=1)
+        return languagues
+
+    @classmethod
+    def _get_language(cls, record):
+        pool = Pool()
+        User = pool.get('res.user')
+        if isinstance(record, User) and record.language:
+            return record.language
+
+
+class EmailTemplate_Report(ModelSQL):
+    "Email Template - Report"
+    __name__ = 'ir.email.template-ir.action.report'
+
+    template = fields.Many2One(
+        'ir.email.template', "Template", required=True, ondelete='CASCADE')
+    report = fields.Many2One(
+        'ir.action.report', "Report", required=True, ondelete='CASCADE')
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/message.xml
--- a/trytond/ir/message.xml    Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/ir/message.xml    Thu Sep 17 17:18:02 2020 +0200
@@ -311,13 +311,17 @@
         <record model="ir.message" id="msg_notes">
             <field name="text">Notes</field>
         </record>
-
         <record model="ir.message" id="msg_access_wizard_error">
             <field name="text">You are not allowed to execute wizard 
"%(wizard)s".</field>
         </record>
         <record model="ir.message" id="msg_access_wizard_model_error">
             <field name="text">You are not allowed to execute wizard 
"%(wizard)s" on "%(model)s".</field>
         </record>
-
+        <record model="ir.message" id="msg_email_template_invalid_subject">
+            <field name="text">Invalid subject in e-mail template 
"%(template)s" with exception "%(exception)s"</field>
+        </record>
+        <record model="ir.message" id="msg_email_template_invalid_body">
+            <field name="text">Invalid body in e-mail template "%(template)s" 
with exception "%(exception)s"</field>
+        </record>
     </data>
 </tryton>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/tryton.cfg
--- a/trytond/ir/tryton.cfg     Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/ir/tryton.cfg     Thu Sep 17 17:18:02 2020 +0200
@@ -20,3 +20,4 @@
     calendar_.xml
     message.xml
     queue.xml
+    email.xml
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/view/action_report_form.xml
--- a/trytond/ir/view/action_report_form.xml    Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/ir/view/action_report_form.xml    Thu Sep 17 17:18:02 2020 +0200
@@ -34,8 +34,6 @@
             <field name="template_extension"/>
             <label name="extension"/>
             <field name="extension"/>
-            <label name="email"/>
-            <field name="email" colspan="3" widget="pyson"/>
         </page>
         <page name="keywords">
             <field name="keywords" colspan="4"/>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/view/action_wizard_form.xml
--- a/trytond/ir/view/action_wizard_form.xml    Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/ir/view/action_wizard_form.xml    Thu Sep 17 17:18:02 2020 +0200
@@ -15,8 +15,6 @@
             <label name="icon"/>
             <field name="icon"/>
             <newline/>
-            <label name="email"/>
-            <field name="email" colspan="3"/>
             <label name="window"/>
             <field name="window"/>
         </page>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/view/email_form.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/ir/view/email_form.xml    Thu Sep 17 17:18:02 2020 +0200
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<form>
+    <label name="user"/>
+    <field name="user"/>
+    <label name="at"/>
+    <field name="at"/>
+    <label name="recipients"/>
+    <field name="recipients" colspan="3"/>
+    <label name="recipients_secondary"/>
+    <field name="recipients_secondary" colspan="3"/>
+    <label name="recipients_hidden"/>
+    <field name="recipients_hidden" colspan="3"/>
+    <label name="subject"/>
+    <field name="subject" colspan="3"/>
+    <separator name="body" colspan="4"/>
+    <field name="body" colspan="4" widget="richtext" toolbar="0"/>
+</form>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/view/email_list.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/ir/view/email_list.xml    Thu Sep 17 17:18:02 2020 +0200
@@ -0,0 +1,13 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree>
+    <field name="resource" expand="2"/>
+    <field name="user"/>
+    <field name="at" widget="date"/>
+    <field name="at" widget="time"/>
+    <field name="recipients" expand="1"/>
+    <field name="recipients_secondary" expand="1"/>
+    <field name="recipients_hidden" expand="1"/>
+    <field name="subject" expand="2"/>
+</tree>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/view/email_template_form.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/ir/view/email_template_form.xml   Thu Sep 17 17:18:02 2020 +0200
@@ -0,0 +1,20 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<form>
+    <label name="name"/>
+    <field name="name"/>
+    <label name="model"/>
+    <field name="model"/>
+    <label name="recipients"/>
+    <field name="recipients" colspan="3"/>
+    <label name="recipients_secondary"/>
+    <field name="recipients_secondary" colspan="3"/>
+    <label name="recipients_hidden"/>
+    <field name="recipients_hidden" colspan="3"/>
+    <label name="subject"/>
+    <field name="subject" colspan="3"/>
+    <separator name="body" colspan="4"/>
+    <field name="body" colspan="4" widget="richtext"/>
+    <field name="reports" colspan="4"/>
+</form>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/ir/view/email_template_list.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/ir/view/email_template_list.xml   Thu Sep 17 17:18:02 2020 +0200
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree>
+    <field name="model" expand="1"/>
+    <field name="name" expand="2"/>
+</tree>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/model/modelview.py
--- a/trytond/model/modelview.py        Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/model/modelview.py        Thu Sep 17 17:18:02 2020 +0200
@@ -377,6 +377,7 @@
         pool = Pool()
         Action = pool.get('ir.action.keyword')
         Export = pool.get('ir.export')
+        Email = pool.get('ir.email.template')
         key = cls.__name__
         result = cls._view_toolbar_get_cache.get(key)
         if result:
@@ -387,11 +388,15 @@
         exports = Export.search_read(
             [('resource', '=', cls.__name__)],
             fields_names=['name', 'export_fields.name'])
+        emails = Email.search_read(
+            [('model.model', '=', cls.__name__)],
+            fields_names=['name'])
         result = {
             'print': prints,
             'action': actions,
             'relate': relates,
             'exports': exports,
+            'emails': emails,
             }
         cls._view_toolbar_get_cache.set(key, result)
         return result
diff -r c6f20e2e714c -r 8288b688cfeb trytond/res/ir.xml
--- a/trytond/res/ir.xml        Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/res/ir.xml        Thu Sep 17 17:18:02 2020 +0200
@@ -1034,5 +1034,20 @@
             <field name="group" ref="group_admin"/>
         </record>
 
+        <record model="ir.model.access" id="access_ir_email_template">
+            <field name="model" search="[('model', '=', 
'ir.email.template')]"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+        <record model="ir.model.access" id="access_ir_email_template_admin">
+            <field name="model" search="[('model', '=', 
'ir.email.template')]"/>
+            <field name="group" ref="group_admin"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="True"/>
+            <field name="perm_create" eval="True"/>
+            <field name="perm_delete" eval="True"/>
+        </record>
     </data>
 </tryton>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/res/view/user_form.xml
--- a/trytond/res/view/user_form.xml    Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/res/view/user_form.xml    Thu Sep 17 17:18:02 2020 +0200
@@ -16,7 +16,7 @@
             <field name="password" widget="password"/>
             <button name="reset_password" colspan="2" />
             <separator name="signature" colspan="4"/>
-            <field name="signature" colspan="4"/>
+            <field name="signature" colspan="4" widget="richtext"/>
         </page>
         <page string="Actions" id="actions">
             <label name="menu"/>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/res/view/user_form_preferences.xml
--- a/trytond/res/view/user_form_preferences.xml        Wed Sep 16 23:26:09 
2020 +0200
+++ b/trytond/res/view/user_form_preferences.xml        Thu Sep 17 17:18:02 
2020 +0200
@@ -11,7 +11,7 @@
             <label name="password"/>
             <field name="password" widget="password"/>
             <separator name="signature" colspan="4"/>
-            <field name="signature" colspan="4"/>
+            <field name="signature" colspan="4" widget="richtext"/>
         </page>
         <page string="Actions" id="actions">
             <label name="menu"/>
diff -r c6f20e2e714c -r 8288b688cfeb trytond/tests/test_ir.py
--- a/trytond/tests/test_ir.py  Wed Sep 16 23:26:09 2020 +0200
+++ b/trytond/tests/test_ir.py  Thu Sep 17 17:18:02 2020 +0200
@@ -3,7 +3,9 @@
 from dateutil.relativedelta import relativedelta
 import datetime
 import unittest
+from unittest.mock import patch, ANY
 
+from trytond.config import config
 from trytond.pool import Pool
 from trytond.transaction import Transaction
 from .test_tryton import ModuleTestCase, with_transaction
@@ -101,6 +103,122 @@
 
         self.assertEqual(admin_id, admin.id)
 
+    @with_transaction()
+    def test_email_send(self):
+        "Test sending email"
+        pool = Pool()
+        Email = pool.get('ir.email')
+        Report = pool.get('ir.action.report')
+        Attachment = pool.get('ir.attachment')
+
+        report = Report(
+            name="Test Email",
+            model='res.user',
+            report_name='tests.email_send',
+            report_content=b'report',
+            template_extension='txt',
+            )
+        report.save()
+
+        with patch('trytond.ir.email_.sendmail_transactional') as sendmail:
+            email = Email.send(
+                to='"John Doe" <[email protected]>, Jane <[email protected]>',
+                cc='User <[email protected]>',
+                bcc='[email protected]',
+                subject="Email subject",
+                body='<p>Hello</p>',
+                attachments=[('file.txt', b'data')],
+                record=('res.user', 1),
+                reports=[report.id])
+
+            attachments = Attachment.search([
+                    ('resource', '=', str(email)),
+                    ])
+
+        addresses = [
+            '[email protected]',
+            '[email protected]',
+            '[email protected]',
+            '[email protected]']
+        sendmail.assert_called_once_with(
+            config.get('email', 'from'), addresses, ANY, datamanager=ANY)
+        self.assertEqual(
+            email.recipients,
+            '"John Doe" <[email protected]>, Jane <[email protected]>')
+        self.assertEqual(email.recipients_secondary, 'User <[email protected]>')
+        self.assertEqual(email.recipients_hidden, '[email protected]')
+        self.assertEqual(
+            [a.address for a in email.addresses],
+            addresses)
+        self.assertEqual(email.subject, "Email subject")
+        self.assertEqual(email.body, '<p>Hello</p>')
+        self.assertEqual(len(attachments), 2)
+        self.assertEqual(
+            {a.name for a in attachments}, {'file.txt', 'Test Email.txt'})
+        self.assertEqual(
+            {a.data for a in attachments}, {b'data', b'report'})
+
+    @with_transaction()
+    def test_email_template_get(self):
+        "Test email template get"
+        pool = Pool()
+        Template = pool.get('ir.email.template')
+        IrModel = pool.get('ir.model')
+        IrModelField = pool.get('ir.model.field')
+        User = pool.get('res.user')
+
+        admin = User(1)
+        admin.email = '[email protected]'
+        admin.save()
+        model, = IrModel.search([('model', '=', 'res.user')])
+        field, = IrModelField.search([
+                ('model', '=', model.id),
+                ('name', '=', 'id'),
+                ])
+
+        template = Template(
+            model=model,
+            name="Test",
+            recipients=field,
+            subject="Subject: ${record.login}",
+            body="<p>Hello, ${record.name}</p>")
+        template.save()
+
+        values = template.get(admin)
+
+        self.assertEqual(
+            values, {
+                'to': ['Administrator <[email protected]>'],
+                'subject': "Subject: admin",
+                'body': '<p>Hello, Administrator</p>',
+                })
+
+    @with_transaction()
+    def test_email_template_get_default(self):
+        "Test email template get default"
+        pool = Pool()
+        Template = pool.get('ir.email.template')
+        IrModel = pool.get('ir.model')
+        IrModelField = pool.get('ir.model.field')
+        User = pool.get('res.user')
+
+        admin = User(1)
+        admin.email = '[email protected]'
+        admin.save()
+        model, = IrModel.search([('model', '=', 'res.user')])
+        field, = IrModelField.search([
+                ('model', '=', model.id),
+                ('name', '=', 'id'),
+                ])
+
+        values = Template.get_default(User.__name__, admin.id)
+
+        self.assertEqual(
+            values, {
+                'to': ['Administrator <[email protected]>'],
+                'subject': "User: Administrator",
+                })
+
 
 def suite():
     return unittest.TestLoader().loadTestsFromTestCase(IrTestCase)

Reply via email to