[REF] base,*: refactor Qweb engine
* Remove AST in favor of pure Pyhon. This should make it easier for developers to understand and create new directives because they do not need to know AST. * Remove `t-call-options` as it has been merged into `t-options` for more consistency. Support for t-call-options is retained. * Use generators for lists. This increases performances as the rendering can be sent directly without having to wait for the creation of the entire list. * Optimize expressions runtime computation by pre-computing the static parts. Example: '<' + 'div' + '>' + '<' + dynamic_value + '>' Now compiles as: '<div><' + dynamic_value + '>'
This commit is contained in:
@@ -5,7 +5,7 @@ import odoo.tests
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
|
||||
def break_view(view, fr='<p>placeholder</p>', to='<p t-field="not.exist"/>'):
|
||||
def break_view(view, fr='<p>placeholder</p>', to='<p t-field="no_record.exist"/>'):
|
||||
view.arch = view.arch.replace(fr, to)
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ class TestWebsiteResetViews(odoo.tests.HttpCase):
|
||||
def test_02_reset_specific_view_controller(self):
|
||||
total_views = self.View.search_count([('type', '=', 'qweb')])
|
||||
# Trigger COW then break the QWEB XML on it
|
||||
# `t-att-data="not.exist"` will test the case where exception.html contains branding
|
||||
break_view(self.test_view.with_context(website_id=1), to='<p t-att-data="not.exist" />')
|
||||
# `t-att-data="no_record.exist"` will test the case where exception.html contains branding
|
||||
break_view(self.test_view.with_context(website_id=1), to='<p t-att-data="no_record.exist" />')
|
||||
self.assertEqual(total_views + 1, self.View.search_count([('type', '=', 'qweb')]), "Missing COW view")
|
||||
self.fix_it('/test_view')
|
||||
|
||||
@@ -79,12 +79,12 @@ class TestWebsiteResetViews(odoo.tests.HttpCase):
|
||||
# self.assertEqual(total_views + 1, self.View.search_count([('type', '=', 'qweb')]), "Missing COW view (1)")
|
||||
# self.fix_it('/test_view')
|
||||
|
||||
# also mute ir.ui.view as `get_view_id()` will raise "Could not find view object with xml_id 'not.exist'""
|
||||
# also mute ir.ui.view as `get_view_id()` will raise "Could not find view object with xml_id 'no_record.exist'""
|
||||
@mute_logger('odoo.addons.http_routing.models.ir_http', 'odoo.addons.website.models.ir_ui_view')
|
||||
def test_06_reset_specific_view_controller_inexisting_template(self):
|
||||
total_views = self.View.search_count([('type', '=', 'qweb')])
|
||||
# Trigger COW then break the QWEB XML on it
|
||||
break_view(self.test_view.with_context(website_id=1), to='<t t-call="not.exist"/>')
|
||||
break_view(self.test_view.with_context(website_id=1), to='<t t-call="no_record.exist"/>')
|
||||
self.assertEqual(total_views + 1, self.View.search_count([('type', '=', 'qweb')]), "Missing COW view (2)")
|
||||
self.fix_it('/test_view')
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
from werkzeug.urls import url_quote
|
||||
from markupsafe import Markup as M
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.tools import pycompat
|
||||
@@ -105,7 +106,7 @@ class Image(models.AbstractModel):
|
||||
img.append('"')
|
||||
img.append('/>')
|
||||
|
||||
return u''.join(img)
|
||||
return M(''.join(img))
|
||||
|
||||
class ImageUrlConverter(models.AbstractModel):
|
||||
_description = 'Qweb Field Image'
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<result id="static"><![CDATA[<div foo="a" bar="b" baz="c"></div>]]></result>
|
||||
|
||||
<t t-name="static-void">
|
||||
<img src="/test.jpg" alt="Test"/>
|
||||
<img src="/test.jpg" alt="Test" loading="lazy"/>
|
||||
</t>
|
||||
<result id="static-void"><![CDATA[<img src="/test.jpg" alt="Test"/>]]></result>
|
||||
<result id="static-void"><![CDATA[<img src="/test.jpg" alt="Test" loading="lazy"/>]]></result>
|
||||
|
||||
<t t-name="fixed-literal">
|
||||
<div t-att-foo="'bar'"/>
|
||||
|
||||
@@ -32,6 +32,27 @@
|
||||
</t>
|
||||
<result id="with-used-setbody">ok</result>
|
||||
|
||||
<t t-name="_call-with-body-arch-lookup">
|
||||
<section t-raw="0"/>
|
||||
</t>
|
||||
|
||||
<t t-name="call-without-body-arch-lookup">
|
||||
<t t-call="_call-with-body-arch-lookup"/>
|
||||
</t>
|
||||
<result id="call-without-body-arch-lookup"><![CDATA[<section></section>]]></result>
|
||||
|
||||
<t t-name="call-with-body-arch-lookup">
|
||||
<t t-call="_call-with-body-arch-lookup">
|
||||
<div><span class="toto" t-esc="value"/></div>
|
||||
</t>
|
||||
</t>
|
||||
<params id="call-with-body-arch-lookup">
|
||||
{"value": "ok"}
|
||||
</params>
|
||||
<result id="call-with-body-arch-lookup"><![CDATA[<section>
|
||||
<div><span class="toto">ok</span></div>
|
||||
</section>]]></result>
|
||||
|
||||
<!--
|
||||
postfix to call removed because Python impl appends all whitespace
|
||||
following called template's root to template result (+= element.tail)
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
</t>
|
||||
<result id="raw-literal">ok</result>
|
||||
|
||||
<t t-name="raw-number">
|
||||
<t t-raw="1"/>
|
||||
</t>
|
||||
<result id="raw-number">1</result>
|
||||
|
||||
<t t-name="raw-variable">
|
||||
<t t-raw="var"/>
|
||||
</t>
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
ok
|
||||
</result>
|
||||
|
||||
<t t-name="set-from-body-arch-lookup">
|
||||
<t t-set="stuff"><div><span t-esc="value"/></div></t>
|
||||
<t t-raw="stuff"/>
|
||||
</t>
|
||||
<params id="set-from-body-arch-lookup">
|
||||
{"value": "ok"}
|
||||
</params>
|
||||
<result id="set-from-body-arch-lookup"><![CDATA[<div><span>ok</span></div>]]></result>
|
||||
|
||||
<t t-name="set-empty-body">
|
||||
<t t-set="stuff"/>
|
||||
<t t-esc="stuff"/>
|
||||
|
||||
@@ -8,7 +8,6 @@ as well as render a few fields differently.
|
||||
Also, adds methods to convert values back to Odoo models.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import babel
|
||||
import base64
|
||||
import io
|
||||
@@ -47,7 +46,7 @@ class QWeb(models.AbstractModel):
|
||||
|
||||
# compile directives
|
||||
|
||||
def _compile_node(self, el, options):
|
||||
def _compile_node(self, el, options, indent):
|
||||
snippet_key = options.get('snippet-key')
|
||||
if snippet_key == options['template'] \
|
||||
or options.get('snippet-sub-call-key') == options['template']:
|
||||
@@ -64,56 +63,58 @@ class QWeb(models.AbstractModel):
|
||||
# The first node might be a call to a sub template
|
||||
sub_call = el.get('t-call')
|
||||
if sub_call:
|
||||
el.set('t-call-options', f"{{'snippet-key': '{snippet_key}', 'snippet-sub-call-key': '{sub_call}'}}")
|
||||
el.set('t-options', f"{{'snippet-key': '{snippet_key}', 'snippet-sub-call-key': '{sub_call}'}}")
|
||||
# If it already has a data-snippet it is a saved snippet.
|
||||
# Do not override it.
|
||||
elif 'data-snippet' not in el.attrib:
|
||||
el.attrib['data-snippet'] = snippet_key.split('.', 1)[-1]
|
||||
|
||||
return super()._compile_node(el, options)
|
||||
return super()._compile_node(el, options, indent)
|
||||
|
||||
def _compile_directive_snippet(self, el, options):
|
||||
def _compile_directive_snippet(self, el, options, indent):
|
||||
key = el.attrib.pop('t-snippet')
|
||||
el.set('t-call', key)
|
||||
el.set('t-call-options', "{'snippet-key': '" + key + "'}")
|
||||
el.set('t-options', "{'snippet-key': '" + key + "'}")
|
||||
View = self.env['ir.ui.view'].sudo()
|
||||
view_id = View.get_view_id(key)
|
||||
name = View.browse(view_id).name
|
||||
thumbnail = el.attrib.pop('t-thumbnail', "oe-thumbnail")
|
||||
div = u'<div name="%s" data-oe-type="snippet" data-oe-thumbnail="%s" data-oe-snippet-id="%s" data-oe-keywords="%s">' % (
|
||||
div = '<div name="%s" data-oe-type="snippet" data-oe-thumbnail="%s" data-oe-snippet-id="%s" data-oe-keywords="%s">' % (
|
||||
escape(pycompat.to_text(name)),
|
||||
escape(pycompat.to_text(thumbnail)),
|
||||
escape(pycompat.to_text(view_id)),
|
||||
escape(pycompat.to_text(el.findtext('keywords')))
|
||||
)
|
||||
return [self._append(ast.Str(div))] + self._compile_node(el, options) + [self._append(ast.Str(u'</div>'))]
|
||||
self._appendText(div, options)
|
||||
code = self._compile_node(el, options, indent)
|
||||
self._appendText('</div>', options)
|
||||
return code
|
||||
|
||||
def _compile_directive_snippet_call(self, el, options):
|
||||
def _compile_directive_snippet_call(self, el, options, indent):
|
||||
key = el.attrib.pop('t-snippet-call')
|
||||
el.set('t-call', key)
|
||||
el.set('t-call-options', "{'snippet-key': '" + key + "'}")
|
||||
return self._compile_node(el, options)
|
||||
el.set('t-options', "{'snippet-key': '" + key + "'}")
|
||||
return self._compile_node(el, options, indent)
|
||||
|
||||
def _compile_directive_install(self, el, options):
|
||||
def _compile_directive_install(self, el, options, indent):
|
||||
if self.user_has_groups('base.group_system'):
|
||||
module = self.env['ir.module.module'].search([('name', '=', el.attrib.get('t-install'))])
|
||||
if not module or module.state == 'installed':
|
||||
return []
|
||||
name = el.attrib.get('string') or 'Snippet'
|
||||
thumbnail = el.attrib.pop('t-thumbnail', 'oe-thumbnail')
|
||||
div = u'<div name="%s" data-oe-type="snippet" data-module-id="%s" data-oe-thumbnail="%s"><section/></div>' % (
|
||||
div = '<div name="%s" data-oe-type="snippet" data-module-id="%s" data-oe-thumbnail="%s"><section/></div>' % (
|
||||
escape(pycompat.to_text(name)),
|
||||
module.id,
|
||||
escape(pycompat.to_text(thumbnail))
|
||||
)
|
||||
return [self._append(ast.Str(div))]
|
||||
else:
|
||||
return []
|
||||
self._appendText(div, options)
|
||||
return []
|
||||
|
||||
def _compile_directive_tag(self, el, options):
|
||||
def _compile_directive_tag(self, el, options, indent):
|
||||
if el.get('t-placeholder'):
|
||||
el.set('t-att-placeholder', el.attrib.pop('t-placeholder'))
|
||||
return super(QWeb, self)._compile_directive_tag(el, options)
|
||||
return super(QWeb, self)._compile_directive_tag(el, options, indent)
|
||||
|
||||
# order and ignore
|
||||
|
||||
@@ -589,7 +590,7 @@ def _realize_padding(it):
|
||||
# leftover padding irrelevant as the output will be stripped
|
||||
|
||||
|
||||
def _wrap(element, output, wrapper=u''):
|
||||
def _wrap(element, output, wrapper=''):
|
||||
""" Recursively extracts text from ``element`` (via _element_to_text), and
|
||||
wraps it all in ``wrapper``. Extracted text is added to ``output``
|
||||
|
||||
@@ -605,7 +606,7 @@ def _wrap(element, output, wrapper=u''):
|
||||
|
||||
def _element_to_text(e, output):
|
||||
if e.tag == 'br':
|
||||
output.append(u'\n')
|
||||
output.append('\n')
|
||||
elif e.tag in _PADDED_BLOCK:
|
||||
_wrap(e, output, 2)
|
||||
elif e.tag in _MISC_BLOCK:
|
||||
|
||||
@@ -380,7 +380,7 @@ class Http(models.AbstractModel):
|
||||
# in the view, either the error is in a child view or the code
|
||||
# contains branding (<div t-att-data="request.browse('ok')"/>).
|
||||
et = view.with_context(inherit_branding=False)._get_combined_arch()
|
||||
node = et.xpath(exception.path)
|
||||
node = et.xpath(exception.path) if exception.path else et
|
||||
line = node is not None and etree.tostring(node[0], encoding='unicode')
|
||||
if line:
|
||||
values['view'] = View._views_get(exception_template).filtered(
|
||||
|
||||
@@ -17,6 +17,22 @@ class TestQweb(TransactionCaseWithUserDemo):
|
||||
get_module_resource(module, *args),
|
||||
{}, 'init', False, 'test')
|
||||
|
||||
def test_qweb_post_processing_att(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-escaping">
|
||||
<img src="http://test.external.img/img.png"/>
|
||||
<img t-att-src="url"/>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<img src="http://test.external.img/img.png" loading="lazy"/>
|
||||
<img src="http://test.external.img/img2.png" loading="lazy"/>
|
||||
"""
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, {'url': 'http://test.external.img/img2.png'}), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_qweb_cdn(self):
|
||||
self._load('website', 'tests', 'template_qweb_test.xml')
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from subprocess import Popen, PIPE
|
||||
import base64
|
||||
@@ -151,7 +150,7 @@ class AssetsBundle(object):
|
||||
extension='')
|
||||
else:
|
||||
href = attachment.url
|
||||
attr = OrderedDict([
|
||||
attr = dict([
|
||||
["type", "text/css"],
|
||||
["rel", "stylesheet"],
|
||||
["href", href],
|
||||
@@ -167,7 +166,7 @@ class AssetsBundle(object):
|
||||
if js and self.javascripts:
|
||||
js_attachment = self.js(is_minified=not is_debug_assets)
|
||||
src = self.get_debug_asset_url(name=js_attachment.name, extension='') if is_debug_assets else js_attachment[0].url
|
||||
attr = OrderedDict([
|
||||
attr = dict([
|
||||
["async", "async" if async_load else None],
|
||||
["defer", "defer" if defer_load or lazy_load else None],
|
||||
["type", "text/javascript"],
|
||||
@@ -819,14 +818,14 @@ class JavascriptAsset(WebAsset):
|
||||
|
||||
def to_node(self):
|
||||
if self.url:
|
||||
return ("script", OrderedDict([
|
||||
return ("script", dict([
|
||||
["type", "text/javascript"],
|
||||
["src", self.html_url],
|
||||
['data-asset-bundle', self.bundle.name],
|
||||
['data-asset-version', self.bundle.version],
|
||||
]), None)
|
||||
else:
|
||||
return ("script", OrderedDict([
|
||||
return ("script", dict([
|
||||
["type", "text/javascript"],
|
||||
["charset", "utf-8"],
|
||||
['data-asset-bundle', self.bundle.name],
|
||||
@@ -922,7 +921,7 @@ class StylesheetAsset(WebAsset):
|
||||
|
||||
def to_node(self):
|
||||
if self.url:
|
||||
attr = OrderedDict([
|
||||
attr = dict([
|
||||
["type", "text/css"],
|
||||
["rel", "stylesheet"],
|
||||
["href", self.html_url],
|
||||
@@ -932,7 +931,7 @@ class StylesheetAsset(WebAsset):
|
||||
])
|
||||
return ("link", attr, None)
|
||||
else:
|
||||
attr = OrderedDict([
|
||||
attr = dict([
|
||||
["type", "text/css"],
|
||||
["media", escape(to_text(self.media)) if self.media else None],
|
||||
['data-asset-bundle', self.bundle.name],
|
||||
|
||||
+122
-183
@@ -1,27 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import print_function
|
||||
import ast
|
||||
from textwrap import dedent
|
||||
import copy
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
import re
|
||||
import reprlib
|
||||
import markupsafe
|
||||
from time import time
|
||||
|
||||
from lxml import html
|
||||
from lxml import etree
|
||||
from lxml import html, etree
|
||||
|
||||
from odoo import api, models, tools
|
||||
from odoo.tools.safe_eval import assert_valid_codeobj, _BUILTINS, _SAFE_OPCODES
|
||||
from odoo.tools.safe_eval import check_values, assert_valid_codeobj, _BUILTINS, to_opcodes, _EXPR_OPCODES, _BLACKLIST
|
||||
from odoo.tools.misc import get_lang
|
||||
from odoo.http import request
|
||||
from odoo.modules.module import get_resource_path
|
||||
|
||||
from odoo.addons.base.models.qweb import QWeb, Contextifier, MarkupSafeBytes
|
||||
from odoo.addons.base.models.qweb import QWeb, MarkupSafeBytes
|
||||
from odoo.addons.base.models.assetsbundle import AssetsBundle
|
||||
from odoo.addons.base.models.ir_asset import can_aggregate, STYLE_EXTENSIONS, SCRIPT_EXTENSIONS
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SAFE_QWEB_OPCODES = _EXPR_OPCODES.union(to_opcodes([
|
||||
'MAKE_FUNCTION', 'CALL_FUNCTION', 'CALL_FUNCTION_KW', 'CALL_FUNCTION_EX',
|
||||
'CALL_METHOD', 'LOAD_METHOD',
|
||||
|
||||
'GET_ITER', 'FOR_ITER', 'YIELD_VALUE',
|
||||
'JUMP_FORWARD', 'JUMP_ABSOLUTE',
|
||||
'JUMP_IF_FALSE_OR_POP', 'JUMP_IF_TRUE_OR_POP', 'POP_JUMP_IF_FALSE', 'POP_JUMP_IF_TRUE',
|
||||
|
||||
'LOAD_NAME', 'LOAD_ATTR',
|
||||
'LOAD_FAST', 'STORE_FAST', 'UNPACK_SEQUENCE',
|
||||
'STORE_SUBSCR',
|
||||
'LOAD_GLOBAL',
|
||||
])) - _BLACKLIST
|
||||
|
||||
|
||||
class IrQWeb(models.AbstractModel, QWeb):
|
||||
""" Base QWeb rendering engine
|
||||
* to customize ``t-field`` rendering, subclass ``ir.qweb.field`` and
|
||||
@@ -34,29 +49,37 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
_name = 'ir.qweb'
|
||||
_description = 'Qweb'
|
||||
|
||||
_available_objects = dict(_BUILTINS)
|
||||
_empty_lines = re.compile(r'\n\s*\n')
|
||||
|
||||
@api.model
|
||||
def _render(self, id_or_xml_id, values=None, **options):
|
||||
""" render(id_or_xml_id, values, **options)
|
||||
def _render(self, template, values=None, **options):
|
||||
""" render(template, values, **options)
|
||||
|
||||
Render the template specified by the given name.
|
||||
|
||||
:param id_or_xml_id: name or etree (see get_template)
|
||||
:param template: etree, xml_id, template name (see _get_template)
|
||||
* Call the method ``load`` is not an etree.
|
||||
:param dict values: template values to be used for rendering
|
||||
:param options: used to compile the template (the dict available for the rendering is frozen)
|
||||
* ``load`` (function) overrides the load method
|
||||
* ``profile`` (float) profile the rendering (use astor lib) (filter
|
||||
profile line with time ms >= profile)
|
||||
"""
|
||||
|
||||
:returns: bytes marked as markup-safe (decode to :class:`MarkupSafeBytes`
|
||||
instead of `str`)
|
||||
:rtype: MarkupSafeBytes
|
||||
"""
|
||||
context = dict(self.env.context, dev_mode='qweb' in tools.config['dev_mode'])
|
||||
context.update(options)
|
||||
|
||||
result = super(IrQWeb, self)._render(id_or_xml_id, values=values, **context)
|
||||
result = super(IrQWeb, self)._render(template, values=values, **context).decode('utf-8')
|
||||
|
||||
if b'data-pagebreak=' not in result:
|
||||
return result
|
||||
if not values or not values.get('__keep_empty_lines'):
|
||||
result = IrQWeb._empty_lines.sub('\n', result.strip())
|
||||
|
||||
fragments = html.fragments_fromstring(result.decode('utf-8'))
|
||||
if 'data-pagebreak=' not in result:
|
||||
return MarkupSafeBytes(result.encode('utf-8'))
|
||||
|
||||
fragments = html.fragments_fromstring(result)
|
||||
|
||||
for fragment in fragments:
|
||||
for row in fragment.iterfind('.//tr[@data-pagebreak]'):
|
||||
@@ -84,13 +107,6 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
|
||||
return MarkupSafeBytes(b''.join(html.tostring(f) for f in fragments))
|
||||
|
||||
def default_values(self):
|
||||
""" attributes add to the values for each computed template
|
||||
"""
|
||||
default = super(IrQWeb, self).default_values()
|
||||
default.update(request=request, cache_assets=round(time()/180), true=True, false=False) # true and false added for backward compatibility to remove after v10
|
||||
return default
|
||||
|
||||
# assume cache will be invalidated by third party on write to ir.ui.view
|
||||
def _get_template_cache_keys(self):
|
||||
""" Return the list of context keys to use for caching ``_get_template``. """
|
||||
@@ -101,12 +117,12 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
'xml' not in tools.config['dev_mode'],
|
||||
tools.ormcache('id_or_xml_id', 'tuple(options.get(k) for k in self._get_template_cache_keys())'),
|
||||
)
|
||||
def compile(self, id_or_xml_id, options):
|
||||
def _compile(self, id_or_xml_id, options):
|
||||
try:
|
||||
id_or_xml_id = int(id_or_xml_id)
|
||||
except:
|
||||
pass
|
||||
return super(IrQWeb, self).compile(id_or_xml_id, options=options)
|
||||
return super(IrQWeb, self)._compile(id_or_xml_id, options=options)
|
||||
|
||||
def _load(self, name, options):
|
||||
lang = options.get('lang', get_lang(self.env).code)
|
||||
@@ -131,152 +147,81 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
for node in view:
|
||||
if node.get('t-name'):
|
||||
node.set('t-name', str(name))
|
||||
return view
|
||||
return (view, view_id)
|
||||
else:
|
||||
return template
|
||||
return (template, view_id)
|
||||
|
||||
# order
|
||||
|
||||
def _directives_eval_order(self):
|
||||
directives = super(IrQWeb, self)._directives_eval_order()
|
||||
directives.insert(directives.index('foreach'), 'groups')
|
||||
directives.insert(directives.index('call'), 'lang')
|
||||
directives.insert(directives.index('field'), 'call-assets')
|
||||
return directives
|
||||
|
||||
# compile
|
||||
|
||||
def _compile_node(self, el, options, indent):
|
||||
if el.get("groups"):
|
||||
el.set("t-groups", el.attrib.pop("groups"))
|
||||
return super(IrQWeb, self)._compile_node(el, options, indent)
|
||||
|
||||
# compile directives
|
||||
|
||||
def _compile_directive_lang(self, el, options):
|
||||
lang = el.attrib.pop('t-lang', get_lang(self.env).code)
|
||||
if el.get('t-call-options'):
|
||||
el.set('t-call-options', el.get('t-call-options')[0:-1] + u', "lang": %s}' % lang)
|
||||
else:
|
||||
el.set('t-call-options', u'{"lang": %s}' % lang)
|
||||
return self._compile_node(el, options)
|
||||
def _compile_directive_groups(self, el, options, indent):
|
||||
"""Compile `t-groups` expressions into a python code as a list of
|
||||
strings.
|
||||
|
||||
def _compile_directive_call_assets(self, el, options):
|
||||
The code will contain the condition `if self.user_has_groups(groups)`
|
||||
part that wrap the rest of the compiled code of this element.
|
||||
"""
|
||||
groups = el.attrib.pop('t-groups')
|
||||
code = self._flushText(options, indent)
|
||||
code.append(self._indent(f"if self.user_has_groups({repr(groups)}):", indent))
|
||||
code.extend(self._compile_directives(el, options, indent + 1) + self._flushText(options, indent + 1) or [self._indent('pass', indent + 1)])
|
||||
return code
|
||||
|
||||
def _compile_directive_lang(self, el, options, indent):
|
||||
el.attrib['t-options-lang'] = el.attrib.pop('t-lang')
|
||||
return self._compile_node(el, options, indent)
|
||||
|
||||
def _compile_directive_call_assets(self, el, options, indent):
|
||||
""" This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
|
||||
if len(el):
|
||||
raise SyntaxError("t-call-assets cannot contain children nodes")
|
||||
|
||||
# nodes = self._get_asset_nodes(bundle, options, css=css, js=js, debug=values.get('debug'), async=async, values=values)
|
||||
#
|
||||
# for index, (tagName, t_attrs, content) in enumerate(nodes):
|
||||
# if index:
|
||||
# append('\n ')
|
||||
# append('<')
|
||||
# append(tagName)
|
||||
#
|
||||
# self._post_processing_att(tagName, t_attrs, options)
|
||||
# for name, value in t_attrs.items():
|
||||
# if value or isinstance(value, string_types)):
|
||||
# append(u' ')
|
||||
# append(name)
|
||||
# append(u'="')
|
||||
# append(escape(pycompat.to_text((value)))
|
||||
# append(u'"')
|
||||
#
|
||||
# if not content and tagName in self._void_elements:
|
||||
# append('/>')
|
||||
# else:
|
||||
# append('>')
|
||||
# if content:
|
||||
# append(content)
|
||||
# append('</')
|
||||
# append(tagName)
|
||||
# append('>')
|
||||
#
|
||||
space = el.getprevious() is not None and el.getprevious().tail or el.getparent().text
|
||||
sep = u'\n' + space.rsplit('\n').pop()
|
||||
return [
|
||||
ast.Assign(
|
||||
targets=[ast.Name(id='nodes', ctx=ast.Store())],
|
||||
value=ast.Call(
|
||||
func=ast.Attribute(
|
||||
value=ast.Name(id='self', ctx=ast.Load()),
|
||||
attr='_get_asset_nodes',
|
||||
ctx=ast.Load()
|
||||
),
|
||||
args=[
|
||||
ast.Str(el.get('t-call-assets')),
|
||||
ast.Name(id='options', ctx=ast.Load()),
|
||||
],
|
||||
keywords=[
|
||||
ast.keyword('css', self._get_attr_bool(el.get('t-css', True))),
|
||||
ast.keyword('js', self._get_attr_bool(el.get('t-js', True))),
|
||||
ast.keyword('debug', ast.Call(
|
||||
func=ast.Attribute(
|
||||
value=ast.Name(id='values', ctx=ast.Load()),
|
||||
attr='get',
|
||||
ctx=ast.Load()
|
||||
),
|
||||
args=[ast.Str('debug')],
|
||||
keywords=[], starargs=None, kwargs=None
|
||||
)),
|
||||
ast.keyword('async_load', self._get_attr_bool(el.get('async_load', False))),
|
||||
ast.keyword('defer_load', self._get_attr_bool(el.get('defer_load', False))),
|
||||
ast.keyword('lazy_load', self._get_attr_bool(el.get('lazy_load', False))),
|
||||
ast.keyword('media', ast.Constant(el.get('media'))),
|
||||
],
|
||||
starargs=None, kwargs=None
|
||||
)
|
||||
),
|
||||
ast.For(
|
||||
target=ast.Tuple(elts=[
|
||||
ast.Name(id='index', ctx=ast.Store()),
|
||||
ast.Tuple(elts=[
|
||||
ast.Name(id='tagName', ctx=ast.Store()),
|
||||
ast.Name(id='t_attrs', ctx=ast.Store()),
|
||||
ast.Name(id='content', ctx=ast.Store())
|
||||
], ctx=ast.Store())
|
||||
], ctx=ast.Store()),
|
||||
iter=ast.Call(
|
||||
func=ast.Name(id='enumerate', ctx=ast.Load()),
|
||||
args=[ast.Name(id='nodes', ctx=ast.Load())],
|
||||
keywords=[],
|
||||
starargs=None, kwargs=None
|
||||
),
|
||||
body=[
|
||||
ast.If(
|
||||
test=ast.Name(id='index', ctx=ast.Load()),
|
||||
body=[self._append(ast.Str(sep))],
|
||||
orelse=[]
|
||||
),
|
||||
self._append(ast.Str(u'<')),
|
||||
self._append(ast.Name(id='tagName', ctx=ast.Load())),
|
||||
] + self._append_attributes() + [
|
||||
ast.If(
|
||||
test=ast.BoolOp(
|
||||
op=ast.And(),
|
||||
values=[
|
||||
ast.UnaryOp(ast.Not(), ast.Name(id='content', ctx=ast.Load()), lineno=0, col_offset=0),
|
||||
ast.Compare(
|
||||
left=ast.Name(id='tagName', ctx=ast.Load()),
|
||||
ops=[ast.In()],
|
||||
comparators=[ast.Attribute(
|
||||
value=ast.Name(id='self', ctx=ast.Load()),
|
||||
attr='_void_elements',
|
||||
ctx=ast.Load()
|
||||
)]
|
||||
),
|
||||
]
|
||||
),
|
||||
body=[self._append(ast.Str(u'/>'))],
|
||||
orelse=[
|
||||
self._append(ast.Str(u'>')),
|
||||
ast.If(
|
||||
test=ast.Name(id='content', ctx=ast.Load()),
|
||||
body=[self._append(ast.Name(id='content', ctx=ast.Load()))],
|
||||
orelse=[]
|
||||
),
|
||||
self._append(ast.Str(u'</')),
|
||||
self._append(ast.Name(id='tagName', ctx=ast.Load())),
|
||||
self._append(ast.Str(u'>')),
|
||||
]
|
||||
)
|
||||
],
|
||||
orelse=[]
|
||||
)
|
||||
]
|
||||
code = self._flushText(options, indent)
|
||||
code.append(self._indent(dedent("""
|
||||
t_call_assets_nodes = self._get_asset_nodes(%(xmlid)s, compile_options, css=%(css)s, js=%(js)s, debug=values.get("debug"), async_load=%(async_load)s, defer_load=%(defer_load)s, lazy_load=%(lazy_load)s, media=%(media)s)
|
||||
for index, (tagName, attrs, content) in enumerate(t_call_assets_nodes):
|
||||
if index:
|
||||
yield '\\n '
|
||||
yield '<'
|
||||
yield tagName
|
||||
""").strip() % {
|
||||
'xmlid': repr(el.get('t-call-assets')),
|
||||
'css': self._compile_bool(el.get('t-css', True)),
|
||||
'js': self._compile_bool(el.get('t-js', True)),
|
||||
'async_load': self._compile_bool(el.get('async_load', False)),
|
||||
'defer_load': self._compile_bool(el.get('defer_load', False)),
|
||||
'lazy_load': self._compile_bool(el.get('lazy_load', False)),
|
||||
'media': repr(el.get('media')) if el.get('media') else False,
|
||||
}, indent))
|
||||
code.extend(self._compile_attributes(options, indent + 1))
|
||||
code.append(self._indent(dedent("""
|
||||
if not content and tagName in self._void_elements:
|
||||
yield '/>'
|
||||
else:
|
||||
yield '>'
|
||||
if content:
|
||||
yield content
|
||||
yield '</'
|
||||
yield tagName
|
||||
yield '>'
|
||||
""").strip(), indent + 1))
|
||||
|
||||
return code
|
||||
|
||||
# method called by computing code
|
||||
|
||||
@@ -385,7 +330,7 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
model = 'ir.qweb.field.' + field_options['type']
|
||||
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
|
||||
|
||||
# get content
|
||||
# get content (the return values from fields are considered to be markup safe)
|
||||
content = converter.record_to_html(record, field_name, field_options)
|
||||
attributes = converter.attributes(record, field_name, field_options, values)
|
||||
|
||||
@@ -403,40 +348,34 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
model = 'ir.qweb.field.' + field_options['type']
|
||||
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
|
||||
|
||||
# get content
|
||||
# get content (the return values from widget are considered to be markup safe)
|
||||
content = converter.value_to_html(value, field_options)
|
||||
attributes = OrderedDict()
|
||||
attributes = {}
|
||||
attributes['data-oe-type'] = field_options['type']
|
||||
attributes['data-oe-expression'] = field_options['expression']
|
||||
|
||||
return (attributes, content, None)
|
||||
|
||||
# compile expression add safe_eval
|
||||
def _prepare_values(self, values, options):
|
||||
""" Prepare the context that will be sent to the evaluated function.
|
||||
|
||||
def _compile_expr(self, expr):
|
||||
""" Compiles a purported Python expression to ast, verifies that it's safe
|
||||
(according to safe_eval's semantics) and alter its variable references to
|
||||
access values data instead
|
||||
:param values: template values to be used for rendering
|
||||
:param options: frozen dict of compilation parameters.
|
||||
"""
|
||||
# string must be stripped otherwise whitespace before the start for
|
||||
# formatting purpose are going to break parse/compile
|
||||
st = ast.parse(expr.strip(), mode='eval')
|
||||
assert_valid_codeobj(
|
||||
_SAFE_OPCODES,
|
||||
compile(st, '<>', 'eval'), # could be expr, but eval *should* be fine
|
||||
expr
|
||||
)
|
||||
check_values(values)
|
||||
values['true'] = True
|
||||
values['false'] = False
|
||||
if 'request' not in values:
|
||||
values['request'] = request
|
||||
return super(IrQWeb, self)._prepare_values(values, options)
|
||||
|
||||
# ast.Expression().body -> expr
|
||||
return Contextifier(_BUILTINS).visit(st).body
|
||||
def _compile_expr(self, expr, raise_on_missing=False):
|
||||
""" Compiles a purported Python expression to compiled code, verifies
|
||||
that it's safe (according to safe_eval's semantics) and alter its
|
||||
variable references to access values data instead
|
||||
|
||||
def _get_attr_bool(self, attr, default=False):
|
||||
if attr:
|
||||
if attr is True:
|
||||
return ast.Constant(True)
|
||||
attr = attr.lower()
|
||||
if attr in ('false', '0'):
|
||||
return ast.Constant(False)
|
||||
elif attr in ('true', '1'):
|
||||
return ast.Constant(True)
|
||||
return ast.Constant(attr if attr is False else bool(default))
|
||||
:param expr: string
|
||||
"""
|
||||
namespace_expr = super(IrQWeb, self)._compile_expr(expr, raise_on_missing=raise_on_missing)
|
||||
assert_valid_codeobj(_SAFE_QWEB_OPCODES, compile(namespace_expr, '<>', 'eval'), expr)
|
||||
return namespace_expr
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from io import BytesIO
|
||||
|
||||
import babel
|
||||
@@ -81,9 +80,9 @@ class FieldConverter(models.AbstractModel):
|
||||
* ``readonly``, has this attribute if the field is readonly
|
||||
* ``expression``, the original expression
|
||||
|
||||
:returns: OrderedDict (attribute name, attribute value).
|
||||
:returns: dict (attribute name, attribute value).
|
||||
"""
|
||||
data = OrderedDict()
|
||||
data = {}
|
||||
field = record._fields[field_name]
|
||||
|
||||
if not options['inherit_branding'] and not options['translate']:
|
||||
@@ -139,7 +138,7 @@ class IntegerConverter(models.AbstractModel):
|
||||
|
||||
@api.model
|
||||
def value_to_html(self, value, options):
|
||||
return pycompat.to_text(self.user_lang().format('%d', value, grouping=True).replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}'))
|
||||
return pycompat.to_text(self.user_lang().format('%d', value, grouping=True).replace(r'-', '-\N{ZERO WIDTH NO-BREAK SPACE}'))
|
||||
|
||||
|
||||
class FloatConverter(models.AbstractModel):
|
||||
@@ -168,7 +167,7 @@ class FloatConverter(models.AbstractModel):
|
||||
value = float_utils.float_round(value, precision_digits=precision)
|
||||
fmt = '%.{precision}f'.format(precision=precision)
|
||||
|
||||
formatted = self.user_lang().format(fmt, value, grouping=True).replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}')
|
||||
formatted = self.user_lang().format(fmt, value, grouping=True).replace(r'-', '-\N{ZERO WIDTH NO-BREAK SPACE}')
|
||||
|
||||
# %f does not strip trailing zeroes. %g does but its precision causes
|
||||
# it to switch to scientific notation starting at a million *and* to
|
||||
@@ -245,11 +244,11 @@ class DateTimeConverter(models.AbstractModel):
|
||||
pattern = options['format']
|
||||
else:
|
||||
if options.get('time_only'):
|
||||
strftime_pattern = (u"%s" % (lang.time_format))
|
||||
strftime_pattern = ("%s" % (lang.time_format))
|
||||
elif options.get('date_only'):
|
||||
strftime_pattern = (u"%s" % (lang.date_format))
|
||||
strftime_pattern = ("%s" % (lang.date_format))
|
||||
else:
|
||||
strftime_pattern = (u"%s %s" % (lang.date_format, lang.time_format))
|
||||
strftime_pattern = ("%s %s" % (lang.date_format, lang.time_format))
|
||||
|
||||
pattern = posix_to_ldml(strftime_pattern, locale=locale)
|
||||
|
||||
@@ -296,7 +295,7 @@ class SelectionConverter(models.AbstractModel):
|
||||
def value_to_html(self, value, options):
|
||||
if not value:
|
||||
return ''
|
||||
return escape(pycompat.to_text(options['selection'][value]) or u'')
|
||||
return escape(pycompat.to_text(options['selection'][value]) or '')
|
||||
|
||||
@api.model
|
||||
def record_to_html(self, record, field_name, options):
|
||||
@@ -346,11 +345,11 @@ class HTMLConverter(models.AbstractModel):
|
||||
# use pos processing for all nodes with attributes
|
||||
for element in body.iter():
|
||||
if element.attrib:
|
||||
attrib = OrderedDict(element.attrib)
|
||||
attrib = dict(element.attrib)
|
||||
attrib = irQweb._post_processing_att(element.tag, attrib, options.get('template_options'))
|
||||
element.attrib.clear()
|
||||
element.attrib.update(attrib)
|
||||
return etree.tostring(body, encoding='unicode', method='html')[6:-7]
|
||||
return M(etree.tostring(body, encoding='unicode', method='html')[6:-7])
|
||||
|
||||
|
||||
class ImageConverter(models.AbstractModel):
|
||||
@@ -376,7 +375,7 @@ class ImageConverter(models.AbstractModel):
|
||||
except: # image.verify() throws "suitable exceptions", I have no idea what they are
|
||||
raise ValueError("Invalid image content")
|
||||
|
||||
return u'<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value.decode('ascii'))
|
||||
return M('<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value.decode('ascii')))
|
||||
|
||||
class ImageUrlConverter(models.AbstractModel):
|
||||
""" ``image_url`` widget rendering, inserts an image tag in the
|
||||
@@ -388,7 +387,7 @@ class ImageUrlConverter(models.AbstractModel):
|
||||
|
||||
@api.model
|
||||
def value_to_html(self, value, options):
|
||||
return u'<img src="%s">' % (value)
|
||||
return M('<img src="%s">' % (value))
|
||||
|
||||
class MonetaryConverter(models.AbstractModel):
|
||||
""" ``monetary`` converter, has a mandatory option
|
||||
@@ -444,13 +443,13 @@ class MonetaryConverter(models.AbstractModel):
|
||||
|
||||
lang = self.user_lang()
|
||||
formatted_amount = lang.format(fmt, display_currency.round(value),
|
||||
grouping=True, monetary=True).replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}')
|
||||
grouping=True, monetary=True).replace(r' ', '\N{NO-BREAK SPACE}').replace(r'-', '-\N{ZERO WIDTH NO-BREAK SPACE}')
|
||||
|
||||
pre = post = u''
|
||||
pre = post = ''
|
||||
if display_currency.position == 'before':
|
||||
pre = u'{symbol}\N{NO-BREAK SPACE}'.format(symbol=display_currency.symbol or '')
|
||||
pre = '{symbol}\N{NO-BREAK SPACE}'.format(symbol=display_currency.symbol or '')
|
||||
else:
|
||||
post = u'\N{NO-BREAK SPACE}{symbol}'.format(symbol=display_currency.symbol or '')
|
||||
post = '\N{NO-BREAK SPACE}{symbol}'.format(symbol=display_currency.symbol or '')
|
||||
|
||||
return M('{pre}<span class="oe_currency_value">{0}</span>{post}').format(formatted_amount, pre=pre, post=post)
|
||||
|
||||
@@ -571,13 +570,13 @@ class DurationConverter(models.AbstractModel):
|
||||
if not v and (secs_per_unit > factor or secs_per_unit < round_to):
|
||||
continue
|
||||
if len(sections):
|
||||
sections.append(u':')
|
||||
sections.append(u"%02.0f" % int(round(v)))
|
||||
return u''.join(sections)
|
||||
sections.append(':')
|
||||
sections.append("%02.0f" % int(round(v)))
|
||||
return ''.join(sections)
|
||||
|
||||
if value < 0:
|
||||
r = -r
|
||||
sections.append(u'-')
|
||||
sections.append('-')
|
||||
for unit, label, secs_per_unit in TIMEDELTA_UNITS:
|
||||
v, r = divmod(r, secs_per_unit)
|
||||
if not v:
|
||||
@@ -592,7 +591,7 @@ class DurationConverter(models.AbstractModel):
|
||||
if section:
|
||||
sections.append(section)
|
||||
|
||||
return u' '.join(sections)
|
||||
return ' '.join(sections)
|
||||
|
||||
|
||||
class RelativeDatetimeConverter(models.AbstractModel):
|
||||
|
||||
+940
-1374
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ from odoo.modules import get_module_resource
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.addons.base.models.qweb import QWebException
|
||||
from odoo.tools import misc, mute_logger
|
||||
from odoo.tools.json import scriptsafe as json_scriptsafe
|
||||
|
||||
unsafe_eval = eval
|
||||
|
||||
|
||||
class TestQWebTField(TransactionCase):
|
||||
@@ -68,7 +71,7 @@ class TestQWebTField(TransactionCase):
|
||||
view1 = self.env['ir.ui.view'].create({
|
||||
'name': "dummy",
|
||||
'type': 'qweb',
|
||||
'arch': u"""
|
||||
'arch': """
|
||||
<t t-name="base.dummy"><root><span t-esc="5" t-options="{'widget': 'char'}" t-options-widget="'float'" t-options-precision="4"/></root></t>
|
||||
"""
|
||||
})
|
||||
@@ -78,7 +81,7 @@ class TestQWebTField(TransactionCase):
|
||||
def test_xss_breakout(self):
|
||||
view = self.env['ir.ui.view'].create({
|
||||
'name': 'dummy', 'type': 'qweb',
|
||||
'arch': u"""
|
||||
'arch': """
|
||||
<t t-name="base.dummy">
|
||||
<root>
|
||||
<script type="application/javascript">
|
||||
@@ -97,7 +100,7 @@ class TestQWebNS(TransactionCase):
|
||||
def test_render_static_xml_with_namespace(self):
|
||||
""" Test the rendering on a namespaced view with no static content. The resulting string should be untouched.
|
||||
"""
|
||||
expected_result = u"""
|
||||
expected_result = """
|
||||
<root>
|
||||
<h:table xmlns:h="http://www.example.org/table">
|
||||
<h:tr>
|
||||
@@ -114,7 +117,7 @@ class TestQWebNS(TransactionCase):
|
||||
view1 = self.env['ir.ui.view'].create({
|
||||
'name': "dummy",
|
||||
'type': 'qweb',
|
||||
'arch': u"""
|
||||
'arch': """
|
||||
<t t-name="base.dummy">%s</t>
|
||||
""" % expected_result
|
||||
})
|
||||
@@ -124,7 +127,7 @@ class TestQWebNS(TransactionCase):
|
||||
def test_render_static_xml_with_namespace_2(self):
|
||||
""" Test the rendering on a namespaced view with no static content. The resulting string should be untouched.
|
||||
"""
|
||||
expected_result = u"""
|
||||
expected_result = """
|
||||
<html xmlns="http://www.w3.org/HTML/1998/html4" xmlns:xdc="http://www.xml.com/books">
|
||||
<head>
|
||||
<title>Book Review</title>
|
||||
@@ -152,7 +155,7 @@ class TestQWebNS(TransactionCase):
|
||||
view1 = self.env['ir.ui.view'].create({
|
||||
'name': "dummy",
|
||||
'type': 'qweb',
|
||||
'arch': u"""
|
||||
'arch': """
|
||||
<t t-name="base.dummy">%s</t>
|
||||
""" % expected_result
|
||||
})
|
||||
@@ -165,7 +168,7 @@ class TestQWebNS(TransactionCase):
|
||||
view1 = self.env['ir.ui.view'].create({
|
||||
'name': "dummy",
|
||||
'type': 'qweb',
|
||||
'arch': u"""
|
||||
'arch': """
|
||||
<t t-name="base.dummy">
|
||||
<root>
|
||||
<h:table xmlns:h="http://www.example.org/table">
|
||||
@@ -664,6 +667,384 @@ class TestQWebNS(TransactionCase):
|
||||
auto_rendered = view._render(values={'partner': partner}).strip().decode()
|
||||
self.assertRegex(auto_rendered, r'<div><img style="width:100%;" alt="Barcode" src="data:image/png;base64,\S+"></div>')
|
||||
|
||||
class TestQWebBasic(TransactionCase):
|
||||
def test_compile_expr(self):
|
||||
tests = [
|
||||
#pylint: disable=C0326
|
||||
# source, values, result
|
||||
("1 +2+ 3", {}, 6),
|
||||
("(((1 +2+ 3)))", {}, 6),
|
||||
("(1) +(2+ (3))", {}, 6),
|
||||
("a == 5", {'a': 5}, True),
|
||||
("{'a': True}", {}, {'a': True}),
|
||||
("object.count(1)", {'object': [1, 2, 1 ,1]}, 3),
|
||||
("dict(a=True)", {}, {'a': True}),
|
||||
("fn(a=11, b=22) or a", {'a': 1, 'fn': lambda a,b: 0}, 1),
|
||||
("fn(a=11, b=22) or a", {'a': 1, 'fn': lambda a,b: b}, 22),
|
||||
("(lambda a: a)(5)", {}, 5),
|
||||
("(lambda a: a[0])([5])", {}, 5),
|
||||
("{'a': lambda a: a[0], 'b': 3}['a']([5])", {}, 5),
|
||||
("list(map(lambda a: a[0], r))", {'r': [(1,11), (2,22)]}, [1, 2]),
|
||||
("z + (head or 'z')", {'z': 'a'}, "az"),
|
||||
("z + (head or 'z')", {'z': 'a', 'head': 'b'}, "ab"),
|
||||
("{a:b for a, b in [(1,11), (2, 22)]}", {}, {1: 11, 2: 22}),
|
||||
("any({x == 2 for x in [1,2,3]})", {}, True),
|
||||
("any({x == 5 for x in [1,2,3]})", {}, False),
|
||||
("{x:y for x,y in [('a', 11),('b', 22)]}", {}, {'a': 11, 'b': 22}),
|
||||
("[(y,x) for x,y in [(1, 11),(2, 22)]]", {}, [(11, 1), (22, 2)]),
|
||||
("(lambda a: a + 5)(a=x)", {'x': 10}, 15),
|
||||
("sum(x for x in range(4)) + ((x))", {'x': 10}, 16),
|
||||
]
|
||||
|
||||
IrQweb = self.env['ir.qweb']
|
||||
for expr, q_values, result in tests:
|
||||
expr_namespace = IrQweb._compile_expr(expr)
|
||||
|
||||
compiled = compile("""def test(values):\n values['result'] = %s""" % expr_namespace, '<test>', 'exec')
|
||||
globals_dict = IrQweb._prepare_globals({}, {})
|
||||
values = {}
|
||||
unsafe_eval(compiled, globals_dict, values)
|
||||
test = values['test']
|
||||
|
||||
test(q_values)
|
||||
q_result = dict(q_values, result=result)
|
||||
self.assertDictEqual(q_values, q_result, "Should compile: %s" % expr)
|
||||
|
||||
def test_compile_expr_security(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-escaping">
|
||||
<div>
|
||||
<t t-set="o" t-value="(lambda a=open: a)()"/>
|
||||
<t t-out="o('/etc/passwd').read()"/>
|
||||
</div>
|
||||
</t>'''
|
||||
})
|
||||
values = {'other': 'any value'}
|
||||
with self.assertRaises(Exception): # NotImplementedError for 'lambda a=open' and Undefined value 'open'.
|
||||
self.env['ir.qweb']._render(t.id, values)
|
||||
|
||||
def test_foreach_iter_list(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="iter-list">
|
||||
<t t-foreach="[3, 2, 1]" t-as="item">
|
||||
[<t t-esc="item_index"/>: <t t-esc="item"/> <t t-esc="item_value"/>]</t>
|
||||
</t>'''
|
||||
})
|
||||
result = u"""
|
||||
[0: 3 3]
|
||||
[1: 2 2]
|
||||
[2: 1 1]
|
||||
"""
|
||||
|
||||
rendered = str(self.env['ir.qweb']._render(t.id), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_foreach_iter_dict(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="iter-dict">
|
||||
<t t-foreach="{'a': 3, 'b': 2, 'c': 1}" t-as="item">
|
||||
[<t t-esc="item_index"/>: <t t-esc="item"/> <t t-esc="item_value"/>]</t>
|
||||
</t>'''
|
||||
})
|
||||
result = u"""
|
||||
[0: a 3]
|
||||
[1: b 2]
|
||||
[2: c 1]
|
||||
"""
|
||||
|
||||
rendered = str(self.env['ir.qweb']._render(t.id), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_att_escaping_1(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-escaping">
|
||||
<div t-att-bibi="json.dumps(bibi)">1</div>
|
||||
<div t-att-toto="toto">2</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div bibi="{"a": "string", "b": 1}">1</div>
|
||||
<div toto="a'b"c">2</div>
|
||||
"""
|
||||
values = {'json': json_scriptsafe, 'bibi': dict(a='string', b=1), 'toto': "a'b\"c"}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_att_escaping_2(self):
|
||||
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-escaping">
|
||||
<t t-set="abc"> <t t-if="add_abc"><t t-out="add_abc"/> <span a="b"> | </span></t><t t-out="efg"/> </t>
|
||||
<div t-att-abc="abc">123</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div abc=" &#34;yes&#34; <span a="b"> | </span>-efg- ">123</div>
|
||||
"""
|
||||
values = {'add_abc': '"yes"', 'efg': '-efg-'}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_attf_escaping_1(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-escaping">
|
||||
<div t-attf-bibi="a, b > c > #{d}">1</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div bibi="a, b > c > a' > b"c">1</div>
|
||||
"""
|
||||
values = {'d': "a' > b\"c"}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_attf_escaping_2(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-escaping">
|
||||
<a t-attf-href="/link/#{ url }/#{other and 'sub'}">link</a>
|
||||
<a t-attf-href="/link/#{ url }/#{(not other) and 'sub'}">link2</a>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<a href="/link/odoo/sub">link</a>
|
||||
<a href="/link/odoo/">link2</a>
|
||||
"""
|
||||
values = {'url': 'odoo', 'other': True}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_attf_escaping_3(self):
|
||||
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-escaping">
|
||||
<div t-attf-abc="abc #{val} { other }">123</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div abc="abc "yes" { other }">123</div>
|
||||
"""
|
||||
values = {'val': '"yes"'}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_set_body_1(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-set">
|
||||
<t t-set="abc"> <span a="b"> [%s] </span> </t>
|
||||
<div t-att-abc="abc % add_abc">123</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div abc=" <span a="b"> [&#34;yes&#34;] </span> ">123</div>
|
||||
"""
|
||||
values = {'add_abc': '"yes"'}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_set_body_2(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-set">
|
||||
<t t-set="abc"> <span a="b"> toto </span> </t>
|
||||
<div t-att-abc="'[%s]' % abc">123</div>
|
||||
<div class="a1" t-out="abc"/>
|
||||
<div class="a2" t-out="'[%s]' % abc"/>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div abc="[ <span a="b"> toto </span> ]">123</div>
|
||||
<div class="a1"> <span a="b"> toto </span> </div>
|
||||
<div class="a2">[ <span a="b"> toto </span> ]</div>
|
||||
"""
|
||||
rendered = str(self.env['ir.qweb']._render(t.id), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_out_format_1(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="out-format">
|
||||
<t t-set="final_message">Powered by %s%s</t>
|
||||
<div t-out="final_message % (a, b and ('-%s' % b) or '')"/>
|
||||
</t>'''
|
||||
})
|
||||
result = u"""
|
||||
<div>Powered by 1-2</div>
|
||||
"""
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, {'a': 1, 'b': 2}), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_out_format_2(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-set">
|
||||
<t t-set="toto">Toto %s</t>
|
||||
<t t-set="abc"> <span a="b"> [%s , %s] </span> </t>
|
||||
<div t-out="(abc % (add_abc, toto)) % 5">123</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div> <span a="b"> ["yes" , Toto 5] </span> </div>
|
||||
"""
|
||||
values = {'add_abc': '"yes"'}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_out_format_3(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-set">
|
||||
<t t-set="toto">Toto %s</t>
|
||||
<t t-set="abc"> <span a="b"> a </span> </t>
|
||||
<div t-out="(toto + abc) % v">123</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div>Toto "yes" <span a="b"> a </span> </div>
|
||||
"""
|
||||
values = {'v': '"yes"'}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_out_format_4(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-set">
|
||||
<t t-set="abc"> <span a="b"> a </span> </t>
|
||||
<div t-out="(v + abc)">123</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div>"yes" <span a="b"> a </span> </div>
|
||||
"""
|
||||
values = {'v': '"yes"'}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_out_format_5(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-set">
|
||||
<t t-set="abc"> <span a="b"> a </span> </t>
|
||||
<div t-out="(abc + v)">123</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div> <span a="b"> a </span> "yes"</div>
|
||||
"""
|
||||
values = {'v': '"yes"'}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_out_format_6(self):
|
||||
# Use str method will use the string value. t-out will escape this str
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-set">
|
||||
<t t-set="abc"> <span a="b"> a </span> </t>
|
||||
<div t-out="(abc.strip() + v)">123</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div><span a="b"> a </span>"yes"</div>
|
||||
"""
|
||||
values = {'v': '"yes"'}
|
||||
rendered = str(self.env['ir.qweb']._render(t.id, values), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_out_escape_text(self):
|
||||
view1 = self.env['ir.ui.view'].create({
|
||||
'name': "dummy",
|
||||
'type': 'qweb',
|
||||
'arch': """
|
||||
<t t-name="base.dummy"><root><span t-out="text" t-options-widget="'text'"/></root></t>
|
||||
"""
|
||||
})
|
||||
html = str(view1._render({'text': """a
|
||||
b <b>c</b>"""}), 'utf-8')
|
||||
self.assertEqual(html, """<root><span data-oe-type="text" data-oe-expression="text">a<br>
|
||||
b <b>c</b></span></root>""")
|
||||
|
||||
def test_if_from_body(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="attr-set">
|
||||
<t t-set="abc"> <span a="b"> a </span> </t>
|
||||
<div t-if="abc">123</div>
|
||||
<div t-if="not abc">456</div>
|
||||
</t>'''
|
||||
})
|
||||
result = """
|
||||
<div>123</div>
|
||||
"""
|
||||
rendered = str(self.env['ir.qweb']._render(t.id), 'utf-8')
|
||||
self.assertEqual(rendered.strip(), result.strip())
|
||||
|
||||
def test_error_message_1(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="test">
|
||||
<section>
|
||||
<div t-esc="abc + def">
|
||||
<span>content</span>
|
||||
</div>
|
||||
</section>
|
||||
</t>'''
|
||||
})
|
||||
with self.assertRaises(QWebException):
|
||||
self.env['ir.qweb']._render(t.id)
|
||||
|
||||
try:
|
||||
self.env['ir.qweb']._render(t.id)
|
||||
except QWebException as e:
|
||||
self.assertIn('<div t-esc="abc + def"/>', e.message)
|
||||
|
||||
def test_error_message_2(self):
|
||||
t = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': '''<t t-name="test">
|
||||
<section>
|
||||
<div t-esc="abc + def + (">
|
||||
<span>content</span>
|
||||
</div>
|
||||
</section>
|
||||
</t>'''
|
||||
})
|
||||
with self.assertRaises(QWebException):
|
||||
self.env['ir.qweb']._render(t.id)
|
||||
|
||||
try:
|
||||
self.env['ir.qweb']._render(t.id)
|
||||
except QWebException as e:
|
||||
self.assertIn('Can not compile expression', e.message)
|
||||
self.assertIn('<div t-esc="abc + def + ("/>', e.message)
|
||||
|
||||
from copy import deepcopy
|
||||
class FileSystemLoader(object):
|
||||
@@ -684,10 +1065,9 @@ class FileSystemLoader(object):
|
||||
root = etree.Element('templates')
|
||||
root.append(deepcopy(node))
|
||||
arch = etree.tostring(root, encoding='unicode')
|
||||
return arch
|
||||
return (arch, name)
|
||||
|
||||
|
||||
class TestQWeb(TransactionCase):
|
||||
class TestQWebStaticXml(TransactionCase):
|
||||
matcher = re.compile(r'^qweb-test-(.*)\.xml$')
|
||||
|
||||
@classmethod
|
||||
@@ -736,6 +1116,12 @@ class TestQWeb(TransactionCase):
|
||||
template
|
||||
)
|
||||
|
||||
def load_tests(loader, suite, _):
|
||||
# can't override TestQWebStaticXml.__dir__ because dir() called on *class* not
|
||||
# instance
|
||||
suite.addTests(TestQWebStaticXml.get_cases())
|
||||
return suite
|
||||
|
||||
class TestPageSplit(TransactionCase):
|
||||
# need to explicitly assertTreesEqual because I guess it's registered for
|
||||
# equality between _Element *or* HtmlElement but we're comparing a parsed
|
||||
@@ -839,8 +1225,3 @@ class TestEmptyLines(TransactionCase):
|
||||
self.assertTrue(re.compile('^\s+\n').match(rendered))
|
||||
self.assertTrue(re.compile('\n\s+\n').match(rendered))
|
||||
|
||||
def load_tests(loader, suite, _):
|
||||
# can't override TestQWeb.__dir__ because dir() called on *class* not
|
||||
# instance
|
||||
suite.addTests(TestQWeb.get_cases())
|
||||
return suite
|
||||
|
||||
@@ -229,7 +229,7 @@ class TestViewInheritance(ViewCase):
|
||||
self.assertEqual(default_tree, self.view_ids['C'].id)
|
||||
|
||||
def test_no_default_view(self):
|
||||
self.assertFalse(self.View.default_view(model='does.not.exist', view_type='form'))
|
||||
self.assertFalse(self.View.default_view(model='no_model.exist', view_type='form'))
|
||||
self.assertFalse(self.View.default_view(model=self.model, view_type='graph'))
|
||||
|
||||
def test_no_recursion(self):
|
||||
|
||||
Reference in New Issue
Block a user