[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:
Gorash
2021-08-03 15:37:54 +00:00
parent ddb0946e7a
commit 5913b7265e
15 changed files with 1568 additions and 1631 deletions
@@ -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')
+2 -1
View File
@@ -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"/>
+21 -20
View File
@@ -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:
+1 -1
View File
@@ -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(
+16
View File
@@ -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')
+6 -7
View File
@@ -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
View File
@@ -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
+21 -22
View File
@@ -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):
File diff suppressed because it is too large Load Diff
+396 -15
View File
@@ -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="{&#34;a&#34;: &#34;string&#34;, &#34;b&#34;: 1}">1</div>
<div toto="a&#39;b&#34;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=" &amp;#34;yes&amp;#34; &lt;span a=&#34;b&#34;&gt; | &lt;/span&gt;-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 &gt; c &gt; #{d}">1</div>
</t>'''
})
result = """
<div bibi="a, b &gt; c &gt; a&#39; &gt; b&#34;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 &#34;yes&#34; { 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=" &lt;span a=&#34;b&#34;&gt; [&amp;#34;yes&amp;#34;] &lt;/span&gt; ">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="[ &lt;span a=&#34;b&#34;&gt; toto &lt;/span&gt; ]">123</div>
<div class="a1"> <span a="b"> toto </span> </div>
<div class="a2">[ &lt;span a=&#34;b&#34;&gt; toto &lt;/span&gt; ]</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"> [&#34;yes&#34; , 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 &#34;yes&#34; <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>&#34;yes&#34; <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> &#34;yes&#34;</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>&#34;yes&#34;</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 &lt;b&gt;c&lt;/b&gt;</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
+1 -1
View File
@@ -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):