Send client email via server

issue9449
review292001002
This commit is contained in:
Cédric Krier
2020-09-17 17:18:02 +02:00
parent ab4bcc470c
commit 82e64bf66a
18 changed files with 785 additions and 43 deletions
+2
View File
@@ -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
+5
View File
@@ -21,6 +21,7 @@ from . import session
from . import queue
from . import calendar_
from . import message
from . import email_
from . import routes
__all__ = ['register', 'routes']
@@ -94,6 +95,10 @@ def register():
calendar_.Month,
calendar_.Day,
message.Message,
email_.Email,
email_.EmailAddress,
email_.EmailTemplate,
email_.EmailTemplate_Report,
module='ir', type_='model')
Pool.register(
translation.TranslationSet,
+3 -35
View File
@@ -107,7 +107,7 @@ class Action(DeactivableMixin, ModelSQL, ModelView):
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 @@ class Action(DeactivableMixin, ModelSQL, ModelView):
'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 @@ class ActionReport(ActionMixin, ModelSQL, ModelView):
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': 'test@example.com', 'cc': 'user@example.com'}")
pyson_email = fields.Function(fields.Char('PySON Email'), 'get_pyson')
_template_cache = MemoryCache('ir.action.report.template', context=False)
@classmethod
@@ -596,31 +592,6 @@ class ActionReport(ActionMixin, ModelSQL, ModelView):
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 @@ class ActionReport(ActionMixin, ModelSQL, ModelView):
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 @@ class ActionWizard(ActionMixin, ModelSQL, ModelView):
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
+91
View File
@@ -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>
+478
View File
@@ -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')
+6 -2
View File
@@ -311,13 +311,17 @@ this repository contains the full copyright notices and license terms. -->
<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>
+1
View File
@@ -20,3 +20,4 @@ xml:
calendar_.xml
message.xml
queue.xml
email.xml
-2
View File
@@ -34,8 +34,6 @@ this repository contains the full copyright notices and license terms. -->
<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"/>
-2
View File
@@ -15,8 +15,6 @@ this repository contains the full copyright notices and license terms. -->
<label name="icon"/>
<field name="icon"/>
<newline/>
<label name="email"/>
<field name="email" colspan="3"/>
<label name="window"/>
<field name="window"/>
</page>
+19
View File
@@ -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>
+13
View File
@@ -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>
+20
View File
@@ -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>
+7
View File
@@ -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>
+5
View File
@@ -377,6 +377,7 @@ class ModelView(Model):
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 @@ class ModelView(Model):
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
+15
View File
@@ -1034,5 +1034,20 @@ this repository contains the full copyright notices and license terms. -->
<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>
+1 -1
View File
@@ -16,7 +16,7 @@ this repository contains the full copyright notices and license terms. -->
<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"/>
+1 -1
View File
@@ -11,7 +11,7 @@ this repository contains the full copyright notices and license terms. -->
<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"/>
+118
View File
@@ -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 @@ class IrTestCase(ModuleTestCase):
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" <john@example.com>, Jane <jane@example.com>',
cc='User <user@example.com>',
bcc='me@example.com',
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 = [
'john@example.com',
'jane@example.com',
'user@example.com',
'me@example.com']
sendmail.assert_called_once_with(
config.get('email', 'from'), addresses, ANY, datamanager=ANY)
self.assertEqual(
email.recipients,
'"John Doe" <john@example.com>, Jane <jane@example.com>')
self.assertEqual(email.recipients_secondary, 'User <user@example.com>')
self.assertEqual(email.recipients_hidden, 'me@example.com')
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 = 'admin@example.com'
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 <admin@example.com>'],
'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 = 'admin@example.com'
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 <admin@example.com>'],
'subject': "User: Administrator",
})
def suite():
return unittest.TestLoader().loadTestsFromTestCase(IrTestCase)