Send client email via server
issue9449 review292001002
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
|
||||
@@ -20,3 +20,4 @@ xml:
|
||||
calendar_.xml
|
||||
message.xml
|
||||
queue.xml
|
||||
email.xml
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user