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)