[IMP] mail, various: add email templates management levels
Purpose ======= Purpose of this commit is to add a new group for the mail template designer. Goal is to make roles clearer: managers edit templates, users use them. This commit allow some designers / managers to make email template and to let others users use those email templates. Specifications ============== When this feature is enabled in the Settings page, a new group is required to modify email templates in a composer like wizard or to make dynamic content. This allows to separate managers editing / composing templates from standard users that use them. If the current does not have this group, the email body will be in readonly mode if he selected an email template. That way we force him to use the email template that the manager made. Technical ========= New Group --------- Only users in this group will be able to create / write email template or to write Jinja code in the mail composer (including other fields like subject in mailing). By default, all internal users have this group. Mass mailing users also have this group as writing mailings is about the same management level as writing templates. Mail Composer Mixin ------------------- In comment mode, the template is rendered and then saved on the body field so non-"Mail Template Editor" users can load email templates. But in mass mode, the body of the template is saved and then rendered and many things change the body (HTML sanitizer, web editor move inline CSS properties, add / remove spaces...). So in this case, we can not know if the user changed the body or not. That is why we put the body field in readonly mode so, it is not modified by the web editor. Jinja code detection -------------------- To detect dynamic Jinja content, we compile the template, and we browse the AST. If we do not have a single "Template Data" node, we assume that the template is dynamic. When we detect the template as static, we do not render it. That way we avoid unnecessary rendering. Code cleaning ------------- Move Jinja import into tools so that it is outside of mail framework code. Task-2187263 closes odoo/odoo#75840 Related: odoo/enterprise#20547 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
committed by
Thibault Delavallée
parent
2623b4591d
commit
cc012a0864
@@ -234,7 +234,7 @@ class Digest(models.Model):
|
||||
'|', ('group_id', 'in', user.groups_id.ids), ('group_id', '=', False)
|
||||
], limit=tips_count)
|
||||
tip_descriptions = [
|
||||
self.env['mail.render.mixin']._render_template(tools.html_sanitize(tip.tip_description), 'digest.tip', tip.ids, post_process=True)[tip.id]
|
||||
self.env['mail.render.mixin'].sudo()._render_template(tools.html_sanitize(tip.tip_description), 'digest.tip', tip.ids, post_process=True)[tip.id]
|
||||
for tip in tips
|
||||
]
|
||||
if consumed:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
'website': 'https://www.odoo.com/app/discuss',
|
||||
'depends': ['base', 'base_setup', 'bus', 'web_tour'],
|
||||
'data': [
|
||||
'data/mail_groups.xml',
|
||||
'wizard/mail_blacklist_remove_views.xml',
|
||||
'wizard/mail_compose_message_views.xml',
|
||||
'wizard/mail_resend_cancel_views.xml',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="group_mail_template_editor" model="res.groups">
|
||||
<field name="name">Mail Template Editor</field>
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
</record>
|
||||
|
||||
<record id="base.group_system" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('mail.group_mail_template_editor'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- By default, allow all users to edit mail templates -->
|
||||
<record id="base.group_user" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('mail.group_mail_template_editor'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -19,3 +19,17 @@ class IrConfigParameter(models.Model):
|
||||
if 'value' in vals and parameter.key in ['mail.bounce.alias', 'mail.catchall.alias'] and vals['value'] != parameter.value:
|
||||
vals['value'] = self.env['mail.alias']._clean_and_check_unique([vals.get('value')])[0]
|
||||
return super().write(vals)
|
||||
|
||||
@api.model
|
||||
def set_param(self, key, value):
|
||||
if key == 'mail.restrict.template.rendering':
|
||||
group_user = self.env.ref('base.group_user')
|
||||
group_mail_template_editor = self.env.ref('mail.group_mail_template_editor')
|
||||
|
||||
if not value and group_mail_template_editor not in group_user.implied_ids:
|
||||
group_user.implied_ids |= group_mail_template_editor
|
||||
|
||||
elif value and group_mail_template_editor in group_user.implied_ids:
|
||||
group_user.implied_ids -= group_mail_template_editor
|
||||
|
||||
return super(IrConfigParameter, self).set_param(key, value)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import api, fields, models, tools, _
|
||||
|
||||
|
||||
class MailComposerMixin(models.AbstractModel):
|
||||
@@ -25,6 +25,7 @@ class MailComposerMixin(models.AbstractModel):
|
||||
body = fields.Html('Contents', sanitize_style=True, compute='_compute_body', store=True, readonly=False)
|
||||
template_id = fields.Many2one('mail.template', 'Mail Template', domain="[('model', '=', render_model)]")
|
||||
# Access
|
||||
is_mail_template_editor = fields.Boolean('Is Editor', compute='_compute_is_mail_template_editor')
|
||||
can_edit_body = fields.Boolean('Can Edit Body', compute='_compute_can_edit_body')
|
||||
|
||||
@api.depends('template_id')
|
||||
@@ -43,7 +44,50 @@ class MailComposerMixin(models.AbstractModel):
|
||||
elif not composer_mixin.body:
|
||||
composer_mixin.body = False
|
||||
|
||||
@api.depends('template_id')
|
||||
@api.depends_context('uid')
|
||||
def _compute_is_mail_template_editor(self):
|
||||
is_mail_template_editor = self.env.is_admin() or self.env.user.has_group('mail.group_mail_template_editor')
|
||||
for record in self:
|
||||
record.is_mail_template_editor = is_mail_template_editor
|
||||
|
||||
@api.depends('template_id', 'is_mail_template_editor')
|
||||
def _compute_can_edit_body(self):
|
||||
self.can_edit_body = True
|
||||
for record in self:
|
||||
record.can_edit_body = (
|
||||
record.is_mail_template_editor
|
||||
or not record.template_id
|
||||
)
|
||||
|
||||
def _render_field(self, field, *args, **kwargs):
|
||||
"""Render the given field on the given records.
|
||||
This method bypass the rights when needed to
|
||||
be able to render the template values in mass mode.
|
||||
"""
|
||||
if field not in self._fields:
|
||||
raise ValueError(_("The field %s does not exist on the model %s", field, self._name))
|
||||
|
||||
composer_value = self[field]
|
||||
|
||||
if (
|
||||
not self.template_id
|
||||
or self.is_mail_template_editor
|
||||
):
|
||||
# Do not need to bypass the verification
|
||||
return super(MailComposerMixin, self)._render_field(field, *args, **kwargs)
|
||||
|
||||
template_field = 'body_html' if field == 'body' else field
|
||||
assert template_field in self.template_id._fields
|
||||
template_value = self.template_id[template_field]
|
||||
|
||||
if field == 'body':
|
||||
sanitized_template_value = tools.html_sanitize(template_value)
|
||||
if not self.can_edit_body or composer_value in (sanitized_template_value, template_value):
|
||||
# Take the previous body which we can trust without HTML editor reformatting
|
||||
self.body = self.template_id.body_html
|
||||
return super(MailComposerMixin, self.sudo())._render_field(field, *args, **kwargs)
|
||||
|
||||
elif composer_value == template_value:
|
||||
# The value is the same as the mail template so we trust it
|
||||
return super(MailComposerMixin, self.sudo())._render_field(field, *args, **kwargs)
|
||||
|
||||
return super(MailComposerMixin, self)._render_field(field, *args, **kwargs)
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
|
||||
import babel
|
||||
import copy
|
||||
import functools
|
||||
import logging
|
||||
import re
|
||||
|
||||
import dateutil.relativedelta as relativedelta
|
||||
from lxml import html
|
||||
from markupsafe import Markup
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import _, api, fields, models, tools
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import UserError, AccessError
|
||||
from odoo.tools import is_html_empty, safe_eval
|
||||
from odoo.tools.jinja import jinja_safe_template_env, jinja_template_env, template_env_globals
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,61 +37,15 @@ def format_time(env, time, tz=False, time_format='medium', lang_code=False):
|
||||
except babel.core.UnknownLocaleError:
|
||||
return time
|
||||
|
||||
def relativedelta_proxy(*args, **kwargs):
|
||||
# dateutil.relativedelta is an old-style class and cannot be directly
|
||||
# instanciated wihtin a jinja2 expression, so a lambda "proxy" is
|
||||
# is needed, apparently
|
||||
return relativedelta.relativedelta(*args, **kwargs)
|
||||
|
||||
template_env_globals = {
|
||||
'str': str,
|
||||
'quote': urls.url_quote,
|
||||
'urlencode': urls.url_encode,
|
||||
'datetime': safe_eval.datetime,
|
||||
'len': len,
|
||||
'abs': abs,
|
||||
'min': min,
|
||||
'max': max,
|
||||
'sum': sum,
|
||||
'filter': filter,
|
||||
'reduce': functools.reduce,
|
||||
'map': map,
|
||||
'relativedelta': relativedelta_proxy,
|
||||
'round': round,
|
||||
}
|
||||
|
||||
try:
|
||||
# We use a jinja2 sandboxed environment to render mako templates.
|
||||
# Note that the rendering does not cover all the mako syntax, in particular
|
||||
# arbitrary Python statements are not accepted, and not all expressions are
|
||||
# allowed: only "public" attributes (not starting with '_') of objects may
|
||||
# be accessed.
|
||||
# This is done on purpose: it prevents incidental or malicious execution of
|
||||
# Python code that may break the security of the server.
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
jinja_template_env = SandboxedEnvironment(
|
||||
block_start_string="<%",
|
||||
block_end_string="%>",
|
||||
variable_start_string="${",
|
||||
variable_end_string="}",
|
||||
comment_start_string="<%doc>",
|
||||
comment_end_string="</%doc>",
|
||||
line_statement_prefix="%",
|
||||
line_comment_prefix="##",
|
||||
trim_blocks=True, # do not output newline after blocks
|
||||
autoescape=True, # XML/HTML automatic escaping
|
||||
)
|
||||
jinja_template_env.globals.update(template_env_globals)
|
||||
jinja_safe_template_env = copy.copy(jinja_template_env)
|
||||
jinja_safe_template_env.autoescape = False
|
||||
except ImportError:
|
||||
_logger.warning("jinja2 not available, templating features will not work!")
|
||||
|
||||
|
||||
class MailRenderMixin(models.AbstractModel):
|
||||
_name = 'mail.render.mixin'
|
||||
_description = 'Mail Render Mixin'
|
||||
|
||||
# If True, we trust the value on the model for rendering
|
||||
# If False, we need the group "Template Editor" to render the model fields
|
||||
_unrestricted_rendering = False
|
||||
|
||||
# language for rendering
|
||||
lang = fields.Char(
|
||||
'Language',
|
||||
@@ -421,6 +374,19 @@ class MailRenderMixin(models.AbstractModel):
|
||||
_logger.info("Failed to load template %r", template_txt, exc_info=True)
|
||||
return results
|
||||
|
||||
if (not self._unrestricted_rendering and template.is_dynamic and not self.env.is_admin() and
|
||||
not self.env.user.has_group('mail.group_mail_template_editor')):
|
||||
group = self.env.ref('mail.group_mail_template_editor')
|
||||
raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name))
|
||||
|
||||
if not template.is_dynamic:
|
||||
# Either the content is a raw text without placeholders, either we fail to
|
||||
# detect placeholders code. In both case we skip the rendering and return
|
||||
# the raw content, so even if we failed to detect dynamic code,
|
||||
# non "mail_template_editor" users will not gain rendering tools available
|
||||
# only for template specific group users
|
||||
return {record_id: template_txt for record_id in res_ids}
|
||||
|
||||
# prepare template variables
|
||||
variables = self._render_jinja_eval_context()
|
||||
if add_context:
|
||||
|
||||
@@ -18,6 +18,8 @@ class MailTemplate(models.Model):
|
||||
_description = 'Email Templates'
|
||||
_order = 'name'
|
||||
|
||||
_unrestricted_rendering = True
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super(MailTemplate, self).default_get(fields)
|
||||
|
||||
@@ -14,6 +14,11 @@ class ResConfigSettings(models.TransientModel):
|
||||
fail_counter = fields.Integer('Fail Mail', readonly=True)
|
||||
alias_domain = fields.Char('Alias Domain', help="If you have setup a catch-all email domain redirected to "
|
||||
"the Odoo server, enter the domain name here.", config_parameter='mail.catchall.domain')
|
||||
restrict_template_rendering = fields.Boolean(
|
||||
'Restrict Template Rendering',
|
||||
config_parameter='mail.restrict.template.rendering',
|
||||
help='Users will still be able to render templates.\n'
|
||||
'However only Mail Template Editors will be able to create new dynamic templates or modify existing ones.')
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
|
||||
@@ -27,7 +27,8 @@ access_mail_tracking_value_portal,mail.tracking.value.portal,model_mail_tracking
|
||||
access_mail_tracking_value_user,mail.tracking.value.user,model_mail_tracking_value,base.group_user,0,0,0,0
|
||||
access_mail_tracking_value_system,mail.tracking.value.system,model_mail_tracking_value,base.group_system,1,1,1,1
|
||||
access_publisher_warranty_contract_all,publisher.warranty.contract.all,model_publisher_warranty_contract,,1,1,1,1
|
||||
access_mail_template,mail.template,model_mail_template,base.group_user,1,1,1,0
|
||||
access_mail_template,mail.template,model_mail_template,base.group_user,1,0,0,0
|
||||
access_mail_template_editor,mail.template_editor,model_mail_template,mail.group_mail_template_editor,1,1,1,1
|
||||
access_mail_template_system,mail.template_system,model_mail_template,base.group_system,1,1,1,1
|
||||
access_mail_shortcode,mail.shortcode,model_mail_shortcode,base.group_user,1,1,1,1
|
||||
access_mail_shortcode_portal,mail.shortcode.portal,model_mail_shortcode,base.group_portal,1,0,0,0
|
||||
|
||||
|
@@ -5,6 +5,7 @@ from . import test_mail_channel
|
||||
from . import test_mail_channel_partner
|
||||
from . import test_mail_full_composer
|
||||
from . import test_mail_render
|
||||
from . import test_mail_template
|
||||
from . import test_mail_tools
|
||||
from . import test_res_partner
|
||||
from . import test_res_users_settings
|
||||
|
||||
@@ -890,10 +890,12 @@ class MailCommon(common.TransactionCase, MailCase):
|
||||
cls.user_root = cls.env.ref('base.user_root')
|
||||
cls.partner_root = cls.user_root.partner_id
|
||||
|
||||
cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', False)
|
||||
|
||||
# test standard employee
|
||||
cls.user_employee = mail_new_test_user(
|
||||
cls.env, login='employee',
|
||||
groups='base.group_user',
|
||||
groups='base.group_user,mail.group_mail_template_editor',
|
||||
company_id=cls.company_admin.id,
|
||||
name='Ernest Employee',
|
||||
notification_type='inbox',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.mail.tests import common
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.tests import tagged, users
|
||||
|
||||
|
||||
@@ -37,7 +38,17 @@ class TestMailRender(common.MailCommon):
|
||||
% else
|
||||
<span>Other Speaker</span>
|
||||
% endif
|
||||
</p>"""
|
||||
</p>""",
|
||||
"""
|
||||
<p>${13 + 13}</p>
|
||||
<h1>This is a test</h1>
|
||||
""",
|
||||
"""
|
||||
<b>Test</b>
|
||||
% if False:
|
||||
<b>Code not executed</b>
|
||||
% endif
|
||||
""",
|
||||
]
|
||||
cls.base_jinja_bits_fr = [
|
||||
'<p>Bonjour</p>',
|
||||
@@ -126,6 +137,20 @@ class TestMailRender(common.MailCommon):
|
||||
'res_id': cls.test_template_jinja.id,
|
||||
})
|
||||
|
||||
# Enable group-based template management
|
||||
cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', True)
|
||||
|
||||
# User without the group "mail.group_mail_template_editor"
|
||||
cls.user_rendering_restricted = common.mail_new_test_user(
|
||||
cls.env, login='user_rendering_restricted',
|
||||
groups='base.group_user',
|
||||
company_id=cls.company_admin.id,
|
||||
name='Jinja Restricted User',
|
||||
notification_type='inbox',
|
||||
signature='--\nErnest'
|
||||
)
|
||||
cls.user_rendering_restricted.groups_id -= cls.env.ref('mail.group_mail_template_editor')
|
||||
|
||||
@users('employee')
|
||||
def test_evaluation_context(self):
|
||||
""" Test evaluation context and various ways of tweaking it. """
|
||||
@@ -210,12 +235,16 @@ class TestMailRender(common.MailCommon):
|
||||
)[partner.id]
|
||||
self.assertIn(expected, result)
|
||||
|
||||
@users('employee')
|
||||
@users('user_rendering_restricted')
|
||||
def test_template_rendering_function_call(self):
|
||||
"""Test the case when the template call a custom function.
|
||||
|
||||
This function should not be called when the template is not rendered.
|
||||
"""
|
||||
partner = self.env['res.partner'].browse(self.render_object.ids)
|
||||
model = 'res.partner'
|
||||
res_ids = self.env[model].search([], limit=1).ids
|
||||
partner = self.env[model].browse(res_ids)
|
||||
MailRenderMixin = self.env['mail.render.mixin']
|
||||
|
||||
def cust_function():
|
||||
# Can not use "MagicMock" in a Jinja sand-boxed environment
|
||||
@@ -231,13 +260,40 @@ class TestMailRender(common.MailCommon):
|
||||
<p>return value</p>"""
|
||||
context = {'cust_function': cust_function}
|
||||
|
||||
result = self.env['mail.render.mixin']._render_template_jinja(
|
||||
result = self.env['mail.render.mixin'].with_user(self.user_admin)._render_template_jinja(
|
||||
src, partner._name, partner.ids,
|
||||
add_context=context
|
||||
)[partner.id]
|
||||
self.assertEqual(expected, result)
|
||||
self.assertTrue(cust_function.call)
|
||||
|
||||
with self.assertRaises(AccessError, msg='Simple user should not be able to render Jinja code'):
|
||||
MailRenderMixin._render_template_jinja(src, model, res_ids, add_context=context)
|
||||
|
||||
@users('user_rendering_restricted')
|
||||
def test_template_render_static(self):
|
||||
"""Test that we render correctly static templates (without placeholders)."""
|
||||
model = 'res.partner'
|
||||
res_ids = self.env[model].search([], limit=1).ids
|
||||
MailRenderMixin = self.env['mail.render.mixin']
|
||||
|
||||
result = MailRenderMixin._render_template_jinja(self.base_jinja_bits[0], model, res_ids)[res_ids[0]]
|
||||
self.assertEqual(result, self.base_jinja_bits[0])
|
||||
|
||||
@users('user_rendering_restricted')
|
||||
def test_template_rendering_restricted(self):
|
||||
"""Test if we correctly detect static template."""
|
||||
res_ids = self.env['res.partner'].search([], limit=1).ids
|
||||
with self.assertRaises(AccessError, msg='Simple user should not be able to render Jinja code'):
|
||||
self.env['mail.render.mixin']._render_template_jinja(self.base_jinja_bits[3], 'res.partner', res_ids)
|
||||
|
||||
@users('employee')
|
||||
def test_template_rendering_unrestricted(self):
|
||||
"""Test if we correctly detect static template."""
|
||||
res_ids = self.env['res.partner'].search([], limit=1).ids
|
||||
result = self.env['mail.render.mixin']._render_template_jinja(self.base_jinja_bits[3], 'res.partner', res_ids)[res_ids[0]]
|
||||
self.assertIn('26', result, 'Template Editor should be able to render Jinja code')
|
||||
|
||||
@users('employee')
|
||||
def test_template_rendering_various(self):
|
||||
""" Test static rendering """
|
||||
@@ -319,3 +375,17 @@ class TestMailRender(common.MailCommon):
|
||||
src, partner._name, partner.ids, engine=engine,
|
||||
)[partner.id]
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
@users('user_rendering_restricted')
|
||||
def test_is_jinja_template_condition_block_restricted(self):
|
||||
"""Test if we correctly detect condition block (which might contains code)."""
|
||||
res_ids = self.env['res.partner'].search([], limit=1).ids
|
||||
with self.assertRaises(AccessError, msg='Simple user should not be able to render Jinja code'):
|
||||
self.env['mail.render.mixin']._render_template_jinja(self.base_jinja_bits[4], 'res.partner', res_ids)
|
||||
|
||||
@users('employee')
|
||||
def test_is_jinja_template_condition_block_unrestricted(self):
|
||||
"""Test if we correctly detect condition block (which might contains code)."""
|
||||
res_ids = self.env['res.partner'].search([], limit=1).ids
|
||||
result = self.env['mail.render.mixin']._render_template_jinja(self.base_jinja_bits[4], 'res.partner', res_ids)[res_ids[0]]
|
||||
self.assertNotIn('Code not executed', result, 'The condition block did not work')
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import Form, users
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.addons.mail.tests.common import MailCommon
|
||||
|
||||
|
||||
class TestMailTemplate(MailCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestMailTemplate, cls).setUpClass()
|
||||
# Enable the Jinja rendering restriction
|
||||
cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', True)
|
||||
cls.user_employee.groups_id -= cls.env.ref('mail.group_mail_template_editor')
|
||||
|
||||
cls.mail_template = cls.env['mail.template'].create({
|
||||
'name': 'Test template',
|
||||
'subject': '${1 + 5}',
|
||||
'body_html': '${4 + 9}',
|
||||
'lang': '${object.lang}',
|
||||
'auto_delete': True,
|
||||
'model_id': cls.env.ref('base.model_res_partner').id,
|
||||
})
|
||||
|
||||
@users('employee')
|
||||
def test_mail_compose_message_content_from_template(self):
|
||||
form = Form(self.env['mail.compose.message'])
|
||||
form.template_id = self.mail_template
|
||||
mail_compose_message = form.save()
|
||||
|
||||
self.assertEqual(mail_compose_message.subject, '6', 'We must trust mail template values')
|
||||
|
||||
@users('employee')
|
||||
def test_mail_compose_message_content_from_template_mass_mode(self):
|
||||
mail_compose_message = self.env['mail.compose.message'].create({
|
||||
'composition_mode': 'mass_mail',
|
||||
'model': 'res.partner',
|
||||
'template_id': self.mail_template.id,
|
||||
'subject': '${1 + 5}',
|
||||
})
|
||||
|
||||
values = mail_compose_message.get_mail_values(self.partner_employee.ids)
|
||||
|
||||
self.assertEqual(values[self.partner_employee.id]['subject'], '6', 'We must trust mail template values')
|
||||
self.assertIn('13', values[self.partner_employee.id]['body_html'], 'We must trust mail template values')
|
||||
|
||||
def test_mail_template_acl(self):
|
||||
# Sanity check
|
||||
self.assertTrue(self.user_admin.has_group('mail.group_mail_template_editor'))
|
||||
self.assertFalse(self.user_employee.has_group('mail.group_mail_template_editor'))
|
||||
|
||||
# Group System can create / write / unlink mail template
|
||||
mail_template = self.env['mail.template'].with_user(self.user_admin).create({'name': 'Test template'})
|
||||
self.assertEqual(mail_template.name, 'Test template')
|
||||
|
||||
mail_template.with_user(self.user_admin).name = 'New name'
|
||||
self.assertEqual(mail_template.name, 'New name')
|
||||
|
||||
# Standard employee can not
|
||||
with self.assertRaises(AccessError):
|
||||
self.env['mail.template'].with_user(self.user_employee).create({})
|
||||
|
||||
with self.assertRaises(AccessError):
|
||||
mail_template.with_user(self.user_employee).name = 'Test write'
|
||||
@@ -44,6 +44,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box"
|
||||
id="restrict_template_rendering_setting">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="restrict_template_rendering"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="restrict_template_rendering"/>
|
||||
<div class="text-muted" id="restrict_template_rendering">
|
||||
Restrict mail templates and Jinja rendering.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
|
||||
@@ -167,6 +167,14 @@ class MailComposer(models.TransientModel):
|
||||
for fname, value in values.items():
|
||||
setattr(self, fname, value)
|
||||
|
||||
def _compute_can_edit_body(self):
|
||||
"""Can edit the body if we are not in "mass_mail" mode because the template is
|
||||
rendered before it's modified.
|
||||
"""
|
||||
non_mass_mail = self.filtered(lambda m: m.composition_mode != 'mass_mail')
|
||||
non_mass_mail.can_edit_body = True
|
||||
super(MailComposer, self - non_mass_mail)._compute_can_edit_body()
|
||||
|
||||
@api.model
|
||||
def get_record_data(self, values):
|
||||
""" Returns a defaults-like dict with initial values for the composition
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
attrs="{'invisible':['|', ('reply_to_mode', '=', 'update'), ('composition_mode', '!=', 'mass_mail')],
|
||||
'required':[('reply_to_mode', '!=', 'update'), ('composition_mode', '=', 'mass_mail')]}"/>
|
||||
</group>
|
||||
<field name="body" options="{'style-inline': true}"/>
|
||||
<field name="can_edit_body" invisible="1"/>
|
||||
<field name="body" options="{'style-inline': true}" attrs="{'readonly': [('can_edit_body', '=', False)]}" force_save="1"/>
|
||||
<group col="4">
|
||||
<field name="attachment_ids" widget="many2many_binary" string="Attach a file" nolabel="1" colspan="2"/>
|
||||
<field name="template_id" options="{'no_create': True}"
|
||||
@@ -78,8 +79,7 @@
|
||||
<button string="Send" attrs="{'invisible': [('is_log', '=', True)]}" name="action_send_mail" type="object" class="btn-primary o_mail_send" data-hotkey="q"/>
|
||||
<button string="Log" attrs="{'invisible': [('is_log', '=', False)]}" name="action_send_mail" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
|
||||
|
||||
<button icon="fa-lg fa-save" type="object"
|
||||
<button icon="fa-lg fa-save" type="object" groups="mail.group_mail_template_editor"
|
||||
name="action_save_as_template" string="Save as new template"
|
||||
class="float-right btn-secondary" help="Save as a new template"/>
|
||||
</footer>
|
||||
|
||||
@@ -23,5 +23,9 @@ professional emails and reuse templates.</field>
|
||||
<record id="base.default_user" model="res.users">
|
||||
<field name="groups_id" eval="[(4,ref('mass_mailing.group_mass_mailing_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_mass_mailing_user" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('mail.group_mail_template_editor'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# Updating mako environement in order to be able to use slug
|
||||
try:
|
||||
from odoo.addons.mail.models.mail_render_mixin import jinja_template_env, jinja_safe_template_env
|
||||
from odoo.tools.jinja import jinja_safe_template_env, jinja_template_env
|
||||
from odoo.addons.http_routing.models.ir_http import slug
|
||||
|
||||
jinja_template_env.globals.update({
|
||||
|
||||
@@ -10,6 +10,8 @@ class SMSTemplate(models.Model):
|
||||
_inherit = ['mail.render.mixin']
|
||||
_description = 'SMS Templates'
|
||||
|
||||
_unrestricted_rendering = True
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super(SMSTemplate, self).default_get(fields)
|
||||
|
||||
@@ -14,7 +14,7 @@ class TestSmsTemplateAccessRights(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.user_admin = mail_new_test_user(cls.env, login='user_system', groups='base.group_system')
|
||||
cls.user_admin = mail_new_test_user(cls.env, login='user_system', groups='base.group_user,base.group_system')
|
||||
cls.basic_user = mail_new_test_user(cls.env, login='user_employee', groups='base.group_user')
|
||||
sms_enabled_models = cls.env['ir.model'].search([('is_mail_thread', '=', True), ('transient', '=', False)])
|
||||
vals = []
|
||||
@@ -26,6 +26,13 @@ class TestSmsTemplateAccessRights(TransactionCase):
|
||||
})
|
||||
cls.sms_templates = cls.env['sms.template'].create(vals)
|
||||
|
||||
cls.sms_dynamic_template = cls.env['sms.template'].sudo().create({
|
||||
'body': '${object.name}',
|
||||
'model_id': cls.env['ir.model'].sudo().search([('model', '=', 'res.partner')]).id,
|
||||
})
|
||||
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'Test Partner'})
|
||||
|
||||
@users('user_employee')
|
||||
@mute_logger('odoo.models.unlink')
|
||||
def test_access_rights_user(self):
|
||||
@@ -61,3 +68,41 @@ class TestSmsTemplateAccessRights(TransactionCase):
|
||||
self.assertTrue(bool(self.env['sms.template'].with_user(admin).browse(sms_template.ids).name))
|
||||
|
||||
sms_template.unlink()
|
||||
|
||||
@users('user_employee')
|
||||
def test_sms_template_rendering_restricted(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('mail.restrict.template.rendering', True)
|
||||
self.basic_user.groups_id -= self.env.ref('mail.group_mail_template_editor')
|
||||
|
||||
sms_composer = self.env['sms.composer'].create({
|
||||
'composition_mode': 'comment',
|
||||
'template_id': self.sms_dynamic_template.id,
|
||||
'res_id': self.partner.id,
|
||||
'res_model': 'res.partner',
|
||||
})
|
||||
|
||||
self.assertEqual(sms_composer.body, self.partner.name, 'Simple user should be able to render SMS template')
|
||||
|
||||
sms_composer.composition_mode = 'mass'
|
||||
self.assertEqual(sms_composer.body, '${object.name}', 'In mass mode, we should not render the template')
|
||||
|
||||
body = sms_composer._prepare_body_values(self.partner)[self.partner.id]
|
||||
self.assertEqual(body, self.partner.name, 'In mass mode, if the user did not change the body, he should be able to render it')
|
||||
|
||||
sms_composer.body = 'New body: ${4 + 9}'
|
||||
with self.assertRaises(AccessError, msg='User should not be able to write new Jinja code'):
|
||||
sms_composer._prepare_body_values(self.partner)
|
||||
|
||||
@users('user_system')
|
||||
def test_sms_template_rendering_unrestricted(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('mail.restrict.template.rendering', True)
|
||||
|
||||
sms_composer = self.env['sms.composer'].create({
|
||||
'composition_mode': 'comment',
|
||||
'template_id': self.sms_dynamic_template.id,
|
||||
'res_id': self.partner.id,
|
||||
'res_model': 'res.partner',
|
||||
})
|
||||
|
||||
body = sms_composer._prepare_body_values(self.partner)[self.partner.id]
|
||||
self.assertIn(self.partner.name, body, 'Template Editor should be able to write new Jinja code')
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
<group col="2">
|
||||
<field name="subject" placeholder="Subject..."/>
|
||||
</group>
|
||||
<field name="body" options="{'style-inline': true}"/>
|
||||
<field name="can_edit_body" invisible="1"/>
|
||||
<field name="body" options="{'style-inline': true}" attrs="{'readonly': [('can_edit_body', '=', False)]}" force_save="1"/>
|
||||
<group>
|
||||
<group>
|
||||
<field name="attachment_ids" widget="many2many_binary"/>
|
||||
|
||||
@@ -405,3 +405,30 @@ class TestMessagePost(TestMailCommon, TestRecipients):
|
||||
subject='About %s' % test_record.name,
|
||||
body_content=test_record.name,
|
||||
attachments=[('first.txt', b'My first attachment', 'text/plain'), ('second.txt', b'My second attachment', 'text/plain')])
|
||||
|
||||
@mute_logger('odoo.addons.mail.models.mail_mail')
|
||||
def test_post_post_w_template_mass_mode(self):
|
||||
test_record = self.env['mail.test.simple'].with_context(self._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'})
|
||||
self.user_employee.write({
|
||||
'groups_id': [(4, self.env.ref('base.group_partner_manager').id)],
|
||||
})
|
||||
|
||||
self._create_template('mail.test.simple', {
|
||||
'partner_to': '%s,%s' % (self.partner_2.id, self.user_admin.partner_id.id),
|
||||
'email_to': 'test@example.com',
|
||||
'email_cc': self.partner_1.email,
|
||||
# After the HTML sanitizer, it will become "<p>Body for: ${object.name}<a href="">link</a></p>"
|
||||
'body_html': 'Body for: ${object.name}<script>test</script><a href="javascript:alert(1)">link</a>',
|
||||
})
|
||||
|
||||
with self.mock_mail_gateway():
|
||||
test_record.with_user(self.user_employee).message_post_with_template(self.email_template.id, composition_mode='mass_mail')
|
||||
|
||||
new_partner = self.env['res.partner'].search([('email', '=', 'test@example.com')])
|
||||
|
||||
self.assertSentEmail(
|
||||
self.user_employee.partner_id,
|
||||
[new_partner],
|
||||
subject='About %s' % test_record.name,
|
||||
body_content=test_record.name,
|
||||
attachments=[])
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<field name="render_model" invisible="1"/>
|
||||
<field name="subject" placeholder="Subject..."/>
|
||||
</group>
|
||||
<field name="body" options="{'style-inline': true}"/>
|
||||
<field name="can_edit_body" invisible="1"/>
|
||||
<field name="body" options="{'style-inline': true}" attrs="{'readonly': [('can_edit_body', '=', False)]}" force_save="1"/>
|
||||
<group>
|
||||
<group>
|
||||
<field name="attachment_ids" widget="many2many_binary"/>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import copy
|
||||
import dateutil.relativedelta as relativedelta
|
||||
import functools
|
||||
import logging
|
||||
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo.tools import safe_eval
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def relativedelta_proxy(*args, **kwargs):
|
||||
# dateutil.relativedelta is an old-style class and cannot be directly
|
||||
# instanciated wihtin a jinja2 expression, so a lambda "proxy" is
|
||||
# is needed, apparently
|
||||
return relativedelta.relativedelta(*args, **kwargs)
|
||||
|
||||
|
||||
try:
|
||||
# We use a jinja2 sandboxed environment to render mako templates.
|
||||
# Note that the rendering does not cover all the mako syntax, in particular
|
||||
# arbitrary Python statements are not accepted, and not all expressions are
|
||||
# allowed: only "public" attributes (not starting with '_') of objects may
|
||||
# be accessed.
|
||||
# This is done on purpose: it prevents incidental or malicious execution of
|
||||
# Python code that may break the security of the server.
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from jinja2.nodes import Template, TemplateData, Output
|
||||
|
||||
class JinjaInspectionSandboxedEnvironment(SandboxedEnvironment):
|
||||
"""Environment used to retrieve the compiled code of the Jinja template."""
|
||||
|
||||
def _parse(self, *args, **kwargs):
|
||||
self.current_code = super()._parse(*args, **kwargs)
|
||||
return self.current_code
|
||||
|
||||
def from_string(self, source, *args, **kwargs):
|
||||
template = super().from_string(source, *args, **kwargs)
|
||||
template.code = self.current_code
|
||||
template.is_dynamic = self._is_current_code_dynamic()
|
||||
return template
|
||||
|
||||
def _is_current_code_dynamic(self):
|
||||
"""Detect if the current code is not purely static.
|
||||
|
||||
Return True / False if the template is dynamic or not.
|
||||
|
||||
A template is dynamic if it contains loop, conditions, comments, variables...
|
||||
|
||||
After the compilation into the AST, if the code is purely static, the AST
|
||||
will be composed by only one node "TemplateData". If it's not the case, it
|
||||
means that the template contains Jinja code and will try to find the line
|
||||
number of this code.
|
||||
"""
|
||||
if not isinstance(self.current_code, Template):
|
||||
return True
|
||||
|
||||
if len(self.current_code.body) != 1:
|
||||
return True
|
||||
|
||||
output = self.current_code.body[0]
|
||||
if not isinstance(output, Output):
|
||||
return True
|
||||
|
||||
if len(output.nodes) != 1:
|
||||
return True
|
||||
|
||||
template_data = output.nodes[0]
|
||||
if not isinstance(template_data, TemplateData):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
jinja_template_env = JinjaInspectionSandboxedEnvironment(
|
||||
block_start_string="<%",
|
||||
block_end_string="%>",
|
||||
variable_start_string="${",
|
||||
variable_end_string="}",
|
||||
comment_start_string="<%doc>",
|
||||
comment_end_string="</%doc>",
|
||||
line_statement_prefix="%",
|
||||
line_comment_prefix="##",
|
||||
trim_blocks=True, # do not output newline after blocks
|
||||
autoescape=True, # XML/HTML automatic escaping
|
||||
)
|
||||
|
||||
template_env_globals = {
|
||||
'str': str,
|
||||
'quote': urls.url_quote,
|
||||
'urlencode': urls.url_encode,
|
||||
'datetime': safe_eval.datetime,
|
||||
'len': len,
|
||||
'abs': abs,
|
||||
'min': min,
|
||||
'max': max,
|
||||
'sum': sum,
|
||||
'filter': filter,
|
||||
'reduce': functools.reduce,
|
||||
'map': map,
|
||||
'relativedelta': relativedelta_proxy,
|
||||
'round': round,
|
||||
}
|
||||
|
||||
jinja_template_env.globals.update(template_env_globals)
|
||||
jinja_safe_template_env = copy.copy(jinja_template_env)
|
||||
jinja_safe_template_env.autoescape = False
|
||||
except ImportError:
|
||||
_logger.warning("jinja2 not available, templating features will not work!")
|
||||
Reference in New Issue
Block a user