[IMP] base, web: static xml templates support serverside inheritance

QWeb templates that show up in the 'qweb' key of a module's manifest
now support server side inheritance and xpath evaluation

QWeb templates that show up in the xmlDependencies of a JS widget are not
impacted at all by theses changes, as they are served through the
Werkzeug sharedMiddleware

A similar syntax than ir.ui.view has been implemented in the QWeb templates
- each template must have a root node, whatever tag works

- the root node of a template must have a t-name containing the name of the template
The name -- without the module's name -- may contain dots pretty much anywhere
Though what is recommended is only underscores in template names

- if a template is to inherit from a parent, the root node has a t-inherit directive
containing either the full name of the template it inherits from which is module_name.template_name
or the name of the template, no module name necessary, if the parent template is in the same module

- there are 2 modes of inheriting
primary: copy the behavior of the parent into the template
extension: modifies the parent in place

Task: 1999528

closes odoo/odoo#33892

Signed-off-by: VincentSchippefilt <VincentSchippefilt@users.noreply.github.com>


Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
This commit is contained in:
Lucas Perais (lpe)
2019-07-24 12:21:59 +00:00
committed by Vincent Schippefilt
co-authored by Julien Mougenot
parent 8be4567ae8
commit fc5878ecc6
7 changed files with 827 additions and 197 deletions
+199 -42
View File
@@ -3,6 +3,7 @@
import babel.messages.pofile
import base64
import copy
import datetime
import functools
import glob
@@ -19,16 +20,15 @@ import re
import sys
import tempfile
import time
import zlib
import werkzeug
import werkzeug.exceptions
import werkzeug.utils
import werkzeug.wrappers
import werkzeug.wsgi
from collections import OrderedDict
from collections import OrderedDict, defaultdict
from werkzeug.urls import url_decode, iri_to_uri
from xml.etree import ElementTree
from lxml import etree
import unicodedata
@@ -36,7 +36,7 @@ import odoo
import odoo.modules.registry
from odoo.api import call_kw, Environment
from odoo.modules import get_module_path, get_resource_path
from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr
from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr, apply_inheritance_specs
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.translate import _
from odoo.tools.misc import str2bool, xlwt, file_open
@@ -67,6 +67,8 @@ CONTENT_MAXAGE = 60 * 60 * 24 * 356
DBNAME_PATTERN = '^[a-zA-Z0-9][a-zA-Z0-9_.-]+$'
COMMENT_PATTERN = r'Modified by [\s\w\-.]+ from [\s\w\-.]+'
#----------------------------------------------------------
# Odoo Web helpers
#----------------------------------------------------------
@@ -195,37 +197,6 @@ def module_boot(db=None):
addons = serverside + dbside
return addons
def concat_xml(file_list):
"""Concatenate xml files
:param list(str) file_list: list of files to check
:returns: (concatenation_result, checksum)
:rtype: (str, str)
"""
checksum = hashlib.new('sha1')
if not file_list:
return '', checksum.hexdigest()
root = None
for fname in file_list:
with open(fname, 'rb') as fp:
contents = fp.read()
checksum.update(contents)
fp.seek(0)
try:
xml = ElementTree.parse(fp).getroot()
except ElementTree.ParseError as e:
_logger.error("Could not parse file %s: %s" % (fname, e.msg))
raise e
if root is None:
root = ElementTree.Element(xml.tag)
#elif root.tag != xml.tag:
# raise ValueError("Root tags missmatch: %r != %r" % (root.tag, xml.tag))
for child in xml.getchildren():
root.append(child)
return ElementTree.tostring(root, 'utf-8'), checksum.hexdigest()
def fs2web(path):
"""convert FS path into web path"""
@@ -234,8 +205,7 @@ def fs2web(path):
def manifest_glob(extension, addons=None, db=None, include_remotes=False):
if addons is None:
addons = module_boot(db=db)
else:
addons = addons.split(',')
r = []
for addon in addons:
manifest = http.addons_manifest.get(addon, None)
@@ -247,12 +217,13 @@ def manifest_glob(extension, addons=None, db=None, include_remotes=False):
for pattern in globlist:
if pattern.startswith(('http://', 'https://', '//')):
if include_remotes:
r.append((None, pattern))
r.append((None, pattern, addon))
else:
for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
r.append((path, fs2web(path[len(addons_path):])))
r.append((path, fs2web(path[len(addons_path):]), addon))
return r
def manifest_list(extension, mods=None, db=None, debug=None):
""" list resources to load specifying either:
mods: a comma separated string listing modules
@@ -260,8 +231,9 @@ def manifest_list(extension, mods=None, db=None, debug=None):
"""
if debug is not None:
_logger.warning("odoo.addons.web.main.manifest_list(): debug parameter is deprecated")
mods = mods.split(',')
files = manifest_glob(extension, addons=mods, db=db, include_remotes=True)
return [wp for _fp, wp in files]
return [wp for _fp, wp, addon in files]
def get_last_modified(files):
""" Returns the modification time of the most recently modified
@@ -450,6 +422,191 @@ def _admin_password_warn(uid):
subtype='mail.mt_comment'
)
class HomeStaticTemplateHelpers(object):
"""
Helper Class that wraps the reading of static qweb templates files
and xpath inheritance applied to those templates
/!\ Template inheritance order is defined by ir.module.module natural order
which is "sequence, name"
Then a topological sort is applied, which just puts dependencies
of a module before that module
"""
NAME_TEMPLATE_DIRECTIVE = 't-name'
STATIC_INHERIT_DIRECTIVE = 't-inherit'
STATIC_INHERIT_MODE_DIRECTIVE = 't-inherit-mode'
PRIMARY_MODE = 'primary'
EXTENSION_MODE = 'extension'
DEFAULT_MODE = PRIMARY_MODE
def __init__(self, addons, db, checksum_only=False, debug=False):
'''
:param str|list addons: plain list or comma separated list of addons
:param str db: the current db we are working on
:param bool checksum_only: only computes the checksum of all files for addons
:param str debug: the debug mode of the session
'''
super(HomeStaticTemplateHelpers, self).__init__()
self.addons = addons.split(',') if isinstance(addons, str) else addons
self.db = db
self.debug = debug
self.checksum_only = checksum_only
self.template_dict = OrderedDict()
def _get_parent_template(self, addon, template):
"""Computes the real addon name and the template name
of the parent template (the one that is inherited from)
:param str addon: the addon the template is declared in
:param etree template: the current template we are are handling
:returns: (str, str)
"""
original_template_name = template.attrib[self.STATIC_INHERIT_DIRECTIVE]
split_name_attempt = original_template_name.split('.', 1)
parent_addon, parent_name = tuple(split_name_attempt) if len(split_name_attempt) == 2 else (addon, original_template_name)
if parent_addon not in self.template_dict:
if original_template_name in self.template_dict[addon]:
parent_addon = addon
parent_name = original_template_name
else:
raise ValueError(_('Module %s not loaded or inexistent, or templates of addon being loaded (%s) are misordered') % (parent_addon, addon))
if parent_name not in self.template_dict[parent_addon]:
raise ValueError(_("No template found to inherit from. Module %s and template name %s") % (parent_addon, parent_name))
return parent_addon, parent_name
def _compute_xml_tree(self, addon, file_name, source):
"""Computes the xml tree that 'source' contains
Applies inheritance specs in the process
:param str addon: the current addon we are reading files for
:param str file_name: the current name of the file we are reading
:param str source: the content of the file
:returns: etree
"""
try:
all_templates_tree = etree.parse(io.BytesIO(source), parser=etree.XMLParser(remove_comments=True)).getroot()
except etree.ParseError as e:
_logger.error("Could not parse file %s: %s" % (file_name, e.msg))
raise e
self.template_dict.setdefault(addon, OrderedDict())
for template_tree in list(all_templates_tree):
if self.NAME_TEMPLATE_DIRECTIVE in template_tree.attrib:
template_name = template_tree.attrib[self.NAME_TEMPLATE_DIRECTIVE]
else:
# self.template_dict[addon] grows after processing each template
template_name = 'anonymous_template_%s' % len(self.template_dict[addon])
if self.STATIC_INHERIT_DIRECTIVE in template_tree.attrib:
inherit_mode = template_tree.attrib.get(self.STATIC_INHERIT_MODE_DIRECTIVE, self.DEFAULT_MODE)
if inherit_mode not in [self.PRIMARY_MODE, self.EXTENSION_MODE]:
raise ValueError(_("Invalid inherit mode. Module %s and template name %s") % (addon, template_name))
parent_addon, parent_name = self._get_parent_template(addon, template_tree)
# After several performance tests, we found out that deepcopy is the most efficient
# solution in this case (compared with copy, xpath with '.' and stringifying).
parent_tree = copy.deepcopy(self.template_dict[parent_addon][parent_name])
parent_tag = parent_tree.tag
# replace temporarily the parent tag so it is never the target of the inheritance
parent_tree.tag = 't'
xpaths = list(template_tree)
if self.debug and inherit_mode == self.EXTENSION_MODE:
for xpath in xpaths:
xpath.insert(0, etree.Comment(" Modified by %s from %s " % (template_name, addon)))
inherited_template = apply_inheritance_specs(parent_tree, xpaths)
inherited_template.tag = parent_tag
if inherit_mode == self.PRIMARY_MODE: # New template_tree: A' = B(A)
inherited_template.set(self.NAME_TEMPLATE_DIRECTIVE, template_name)
inherited_template.set(self.STATIC_INHERIT_DIRECTIVE, template_tree.attrib[self.STATIC_INHERIT_DIRECTIVE])
if self.debug:
self._remove_inheritance_comments(inherited_template)
self.template_dict[addon][template_name] = inherited_template
else: # Modifies original: A = B(A)
self.template_dict[parent_addon][parent_name] = inherited_template
else:
if template_name in self.template_dict[addon]:
raise ValueError(_("Template %s already exists in module %s") % (template_name, addon))
self.template_dict[addon][template_name] = template_tree
return all_templates_tree
def _remove_inheritance_comments(self, inherited_template):
'''Remove the comments added in the template already, they come from other templates extending
the base of this inheritance
:param inherited_template:
'''
for comment in inherited_template.xpath('//comment()'):
if re.match(COMMENT_PATTERN, comment.text.strip()):
comment.getparent().remove(comment)
def _manifest_glob(self):
'''Proxy for manifest_glob
Usefull to make 'self' testable'''
return manifest_glob('qweb', self.addons, self.db)
def _read_addon_file(self, file_path):
"""Reads the content of a file given by file_path
Usefull to make 'self' testable
:param str file_path:
:returns: str
"""
with open(file_path, 'rb') as fp:
contents = fp.read()
return contents
def _concat_xml(self, file_dict):
"""Concatenate xml files
:param dict(list) file_dict:
key: addon name
value: list of files for an addon
:returns: (concatenation_result, checksum)
:rtype: (str, str)
"""
checksum = hashlib.new('sha1')
if not file_dict:
return '', checksum.hexdigest()
root = None
for addon, fnames in file_dict.items():
for fname in fnames:
contents = self._read_addon_file(fname)
checksum.update(contents)
if not self.checksum_only:
xml = self._compute_xml_tree(addon, fname, contents)
if root is None:
root = etree.Element(xml.tag)
for addon in self.template_dict.values():
for template in addon.values():
root.append(template)
return etree.tostring(root, encoding='utf-8') if root is not None else '', checksum.hexdigest()
def _get_qweb_templates(self):
"""One and only entry point that gets and evaluates static qweb templates
:rtype: (str, str)
"""
files = OrderedDict([(addon, list()) for addon in self.addons])
[files[f[2]].append(f[0]) for f in self._manifest_glob()]
content, checksum = self._concat_xml(files)
return content, checksum
@classmethod
def get_qweb_templates_checksum(cls, addons, db=None, debug=False):
return cls(addons, db, checksum_only=True, debug=debug)._get_qweb_templates()[1]
@classmethod
def get_qweb_templates(cls, addons, db=None, debug=False):
return cls(addons, db, debug=debug)._get_qweb_templates()[0]
#----------------------------------------------------------
# Odoo Web web Controllers
#----------------------------------------------------------
@@ -589,8 +746,8 @@ class WebClient(http.Controller):
@http.route('/web/webclient/qweb/<string:unique>', type='http', auth="none", cors="*")
def qweb(self, unique, mods=None, db=None):
files = [f[0] for f in manifest_glob('qweb', addons=mods, db=db)]
content, _dummy = concat_xml(files)
content = HomeStaticTemplateHelpers.get_qweb_templates(mods, db, debug=request.session.debug)
return request.make_response(content, [
('Content-Type', 'text/xml'),
('Cache-Control','public, max-age=' + str(CONTENT_MAXAGE))
+2 -3
View File
@@ -7,7 +7,7 @@ from odoo import api, models
from odoo.http import request
from odoo.tools import ustr
from odoo.addons.web.controllers.main import concat_xml, manifest_glob, module_boot
from odoo.addons.web.controllers.main import module_boot, HomeStaticTemplateHelpers
import odoo
@@ -28,8 +28,7 @@ class Http(models.AbstractModel):
user_context = request.session.get_context() if request.session.uid else {}
mods = module_boot()
files = [f[0] for f in manifest_glob('qweb', addons=','.join(mods))]
_, qweb_checksum = concat_xml(files)
qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(addons=mods, debug=request.session.debug)
lang = user_context.get("lang")
translations_per_module, lang_params = request.env['ir.translation'].get_translations_for_webclient(mods, lang)
@@ -51,8 +51,8 @@ QUnit.module('Services', {
});
await testUtils.nextMicrotaskTick();
var $notification = $('body .o_notification_manager .o_notification');
assert.strictEqual(_.str.trim($notification.html().replace(/\s+/g, ' ')),
"<div class=\"toast-header\"> <span role=\"img\" aria-label=\"Notification undefined\" class=\"fa fa-2x mr-3 fa-lightbulb-o o_notification_icon\" title=\"Notification undefined\"></span> <div class=\"d-flex align-items-center mr-auto font-weight-bold o_notification_title\">a</div> </div> <div class=\"toast-body\"> <div class=\"o_notification_content\">b</div> </div>",
assert.strictEqual($notification.html().trim().replace(/\s+/g, ' '),
"<div class=\"toast-header\"> <span class=\"fa fa-2x mr-3 fa-lightbulb-o o_notification_icon\" role=\"img\" aria-label=\"Notification undefined\" title=\"Notification undefined\"></span> <div class=\"d-flex align-items-center mr-auto font-weight-bold o_notification_title\">a</div> </div> <div class=\"toast-body\"> <div class=\"o_notification_content\">b</div> </div>",
"should display notification");
assert.containsNone($notification, '.o_notification_close', "should not display the close button in ");
await testUtils.nextTick();
@@ -72,8 +72,8 @@ QUnit.module('Services', {
});
await testUtils.nextMicrotaskTick();
var $notification = $('body .o_notification_manager .o_notification');
assert.strictEqual(_.str.trim($notification.html().replace(/\s+/g, ' ')),
"<div class=\"toast-header\"> <span role=\"img\" aria-label=\"Notification undefined\" class=\"fa fa-2x mr-3 fa-exclamation o_notification_icon\" title=\"Notification undefined\"></span> <div class=\"d-flex align-items-center mr-auto font-weight-bold o_notification_title\">a</div> </div> <div class=\"toast-body\"> <div class=\"o_notification_content\">b</div> </div>",
assert.strictEqual($notification.html().trim().replace(/\s+/g, ' '),
"<div class=\"toast-header\"> <span class=\"fa fa-2x mr-3 fa-exclamation o_notification_icon\" role=\"img\" aria-label=\"Notification undefined\" title=\"Notification undefined\"></span> <div class=\"d-flex align-items-center mr-auto font-weight-bold o_notification_title\">a</div> </div> <div class=\"toast-body\"> <div class=\"o_notification_content\">b</div> </div>",
"should display notification");
view.destroy();
});
@@ -180,8 +180,8 @@ QUnit.module('Services', {
var $notification = $('body .o_notification_manager .o_notification');
assert.containsOnce($notification.eq(0), '.o_notification_close',
"should display the close button in notification");
assert.strictEqual(_.str.trim($notification.eq(0).html().replace(/\s+/g, ' ')),
"<div class=\"toast-header\"> <span role=\"img\" aria-label=\"Notification undefined\" class=\"fa fa-2x mr-3 fa-question-circle-o o_notification_icon\" title=\"Notification undefined\"></span> <div class=\"d-flex align-items-center mr-auto font-weight-bold o_notification_title\">a0</div> <button aria-label=\"Close\" class=\"mb-1 close o_notification_close\" data-dismiss=\"toast\" type=\"button\"> <span aria-hidden=\"true\" class=\"d-inline\">×</span> </button> </div> <div class=\"toast-body\"> <div class=\"o_notification_content\">b0</div> <div class=\"mt-2 o_notification_buttons\"> <button class=\"btn btn-primary\" type=\"button\"> <span>accept0</span> </button><button class=\"btn btn-secondary\" type=\"button\"> <span>refuse0</span> </button> </div> </div>",
assert.strictEqual($notification.html().trim().replace(/\s+/g, ' '),
"<div class=\"toast-header\"> <span class=\"fa fa-2x mr-3 fa-question-circle-o o_notification_icon\" role=\"img\" aria-label=\"Notification undefined\" title=\"Notification undefined\"></span> <div class=\"d-flex align-items-center mr-auto font-weight-bold o_notification_title\">a0</div> <button type=\"button\" class=\"mb-1 close o_notification_close\" data-dismiss=\"toast\" aria-label=\"Close\"> <span class=\"d-inline\" aria-hidden=\"true\">×</span> </button> </div> <div class=\"toast-body\"> <div class=\"o_notification_content\">b0</div> <div class=\"mt-2 o_notification_buttons\"> <button type=\"button\" class=\"btn btn-primary\"> <span>accept0</span> </button><button type=\"button\" class=\"btn btn-secondary\"> <span>refuse0</span> </button> </div> </div>",
"should display notification");
testUtils.dom.click($notification.find('.o_notification_buttons button:contains(accept0)'));
+416 -1
View File
@@ -2,9 +2,16 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import random
import re
import unittest
from unittest.mock import patch
import textwrap
from datetime import datetime, timedelta
from lxml import etree
from odoo.tests.common import BaseCase
from odoo.tests.common import BaseCase, tagged
from odoo.tools import topological_sort
from odoo.addons.web.controllers.main import HomeStaticTemplateHelpers
def sample(population):
@@ -36,3 +43,411 @@ class TestModulesLoading(BaseCase):
module, sorted_modules.index(module), deps, seen
))
seen.add(module)
@tagged('standard', 'at_install', 'static_templates')
class TestStaticInheritance(unittest.TestCase):
def setUp(self):
super(TestStaticInheritance, self).setUp()
# output is "manifest_glob" return
self.modules = [
('module_1_file_1', None, 'module_1'),
('module_2_file_1', None, 'module_2'),
]
self.template_files = {
'module_1_file_1': b"""
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<t t-name="template_1_2">
<div>And I grew strong</div>
</t>
</templates>
""",
'module_2_file_1': b"""
<templates id="template" xml:space="preserve">
<form t-name="template_2_1" t-inherit="module_1.template_1_1" t-inherit-mode="primary">
<xpath expr="//div[1]" position="after">
<div>I was petrified</div>
</xpath>
<xpath expr="//div[2]" position="after">
<div>But then I spent so many nights thinking how you did me wrong</div>
</xpath>
</form>
<div t-name="template_2_2">
<div>And I learned how to get along</div>
</div>
<form t-inherit="module_1.template_1_2" t-inherit-mode="extension">
<xpath expr="//div[1]" position="after">
<div>And I learned how to get along</div>
</xpath>
</form>
</templates>
""",
}
self._set_patchers()
self._toggle_patchers('start')
self._reg_replace_ws = r"\s|\t"
def tearDown(self):
super(TestStaticInheritance, self).tearDown()
self._toggle_patchers('stop')
# Custom Assert
def assertXMLEqual(self, output, expected):
self.assertTrue(output)
self.assertTrue(expected)
output = textwrap.dedent(output.decode('UTF-8')).strip()
output = re.sub(self._reg_replace_ws, '', output)
expected = textwrap.dedent(expected.decode('UTF-8')).strip()
expected = re.sub(self._reg_replace_ws, '', expected)
self.assertEqual(output, expected)
# Private methods
def _get_module_names(self):
return ','.join([glob[2] for glob in self.modules])
def _set_patchers(self):
def _patched_for_manifest_glob(*args, **kwargs):
# Ordered by module
return self.modules
def _patch_for_read_addon_file(*args, **kwargs):
return self.template_files[args[1]]
self.patchers = [
patch.object(HomeStaticTemplateHelpers, '_manifest_glob', _patched_for_manifest_glob),
patch.object(HomeStaticTemplateHelpers, '_read_addon_file', _patch_for_read_addon_file),
]
def _toggle_patchers(self, mode):
self.assertTrue(mode in ('start', 'stop'))
for p in self.patchers:
getattr(p, mode)()
# Actual test cases
def test_static_inheritance_01(self):
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<t t-name="template_1_2">
<div>And I grew strong</div>
<!-- Modified by anonymous_template_2 from module_2 -->
<div>And I learned how to get along</div>
</t>
<form t-name="template_2_1" random-attr="gloria" t-inherit="module_1.template_1_1">
<div>At first I was afraid</div>
<div>I was petrified</div>
<div>But then I spent so many nights thinking how you did me wrong</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<div t-name="template_2_2">
<div>And I learned how to get along</div>
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
def test_static_inheritance_in_same_module(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
('module_1_file_2', None, 'module_1'),
]
self.template_files = {
'module_1_file_1': b'''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
</templates>
''',
'module_1_file_2': b'''
<templates id="template" xml:space="preserve">
<form t-name="template_1_2" t-inherit="template_1_1" t-inherit-mode="primary">
<xpath expr="//div[1]" position="after">
<div>I was petrified</div>
</xpath>
</form>
</templates>
'''
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
<form t-name="template_1_1">
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<form t-name="template_1_2" t-inherit="template_1_1">
<div>At first I was afraid</div>
<div>I was petrified</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
def test_static_inheritance_in_same_file(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
]
self.template_files = {
'module_1_file_1': b'''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<form t-name="template_1_2" t-inherit="template_1_1" t-inherit-mode="primary">
<xpath expr="//div[1]" position="after">
<div>I was petrified</div>
</xpath>
</form>
</templates>
''',
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
<form t-name="template_1_1">
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<form t-name="template_1_2" t-inherit="template_1_1">
<div>At first I was afraid</div>
<div>I was petrified</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
def test_static_inherit_extended_template(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
]
self.template_files = {
'module_1_file_1': b'''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<form t-name="template_1_2" t-inherit="template_1_1" t-inherit-mode="extension">
<xpath expr="//div[1]" position="after">
<div>I was petrified</div>
</xpath>
</form>
<form t-name="template_1_3" t-inherit="template_1_1" t-inherit-mode="primary">
<xpath expr="//div[3]" position="after">
<div>But then I spent so many nights thinking how you did me wrong</div>
</xpath>
</form>
</templates>
''',
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
<form t-name="template_1_1">
<div>At first I was afraid</div>
<!-- Modified by template_1_2 from module_1 -->
<div>I was petrified</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<form t-name="template_1_3" t-inherit="template_1_1">
<div>At first I was afraid</div>
<div>I was petrified</div>
<div>Kept thinking I could never live without you by my side</div>
<div>But then I spent so many nights thinking how you did me wrong</div>
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
def test_sibling_extension(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
('module_2_file_1', None, 'module_2'),
('module_3_file_1', None, 'module_3'),
]
self.template_files = {
'module_1_file_1': b'''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>I am a man of constant sorrow</div>
<div>I've seen trouble all my days</div>
</form>
</templates>
''',
'module_2_file_1': b'''
<templates id="template" xml:space="preserve">
<form t-name="template_2_1" t-inherit="module_1.template_1_1" t-inherit-mode="extension">
<xpath expr="//div[1]" position="after">
<div>In constant sorrow all through his days</div>
</xpath>
</form>
</templates>
''',
'module_3_file_1': b'''
<templates id="template" xml:space="preserve">
<form t-name="template_3_1" t-inherit="module_1.template_1_1" t-inherit-mode="extension">
<xpath expr="//div[2]" position="after">
<div>Oh Brother !</div>
</xpath>
</form>
</templates>
'''
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
<form t-name="template_1_1">
<div>I am a man of constant sorrow</div>
<!-- Modified by template_2_1 from module_2 -->
<div>In constant sorrow all through his days</div>
<!-- Modified by template_3_1 from module_3 -->
<div>Oh Brother !</div>
<div>I've seen trouble all my days</div>
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
def test_static_misordered_modules(self):
self.modules.reverse()
with self.assertRaises(ValueError) as ve:
HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
self.assertEqual(
str(ve.exception),
'Module module_1 not loaded or inexistent, or templates of addon being loaded (module_2) are misordered'
)
def test_static_misordered_templates(self):
self.template_files['module_2_file_1'] = b"""
<templates id="template" xml:space="preserve">
<form t-name="template_2_1" t-inherit="module_2.template_2_2" t-inherit-mode="primary">
<xpath expr="//div[1]" position="after">
<div>I was petrified</div>
</xpath>
</form>
<div t-name="template_2_2">
<div>And I learned how to get along</div>
</div>
</templates>
"""
with self.assertRaises(ValueError) as ve:
HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
self.assertEqual(
str(ve.exception),
'No template found to inherit from. Module module_2 and template name template_2_2'
)
def _sick_script(self, nMod, nFilePerMod, nTemplatePerFile, stepInheritInModule=2, stepInheritPreviousModule=3):
"""
Make a sick amount of templates to test perf
nMod modules
each module: has nFilesPerModule files, each of which contains nTemplatePerFile templates
"""
self.modules = []
self.template_files = {}
number_templates = 0
for m in range(nMod):
for f in range(nFilePerMod):
mname = 'mod_%s' % m
fname = 'mod_%s_file_%s' % (m, f)
self.modules.append((fname, None, mname))
_file = '<templates id="template" xml:space="preserve">'
for t in range(nTemplatePerFile):
_template = ''
if t % stepInheritInModule or t % stepInheritPreviousModule or t == 0:
_template += """
<div t-name="template_%(t_number)s_mod_%(m_number)s">
<div>Parent</div>
</div>
"""
elif not t % stepInheritInModule and t >= 1:
_template += """
<div t-name="template_%(t_number)s_mod_%(m_number)s"
t-inherit="template_%(t_inherit)s_mod_%(m_number)s"
t-inherit-mode="primary">
<xpath expr="//div[1]" position="before">
<div>Sick XPath</div>
</xpath>
</div>
"""
elif not t % stepInheritPreviousModule and m >= 1:
_template += """
<div t-name="template_%(t_number)s_mod_%(m_number)s"
t-inherit="mod_%(m_module_inherit)s.template_%(t_module_inherit)s_mod_%(m_module_inherit)s"
t-inherit-mode="primary">
<xpath expr="//div[1]" position="inside">
<div>Mental XPath</div>
</xpath>
</div>
"""
if _template:
number_templates += 1
_template_number = 1000 * f + t
_file += _template % {
't_number': _template_number,
'm_number': m,
't_inherit': _template_number - 1,
't_module_inherit': _template_number,
'm_module_inherit': m - 1,
}
_file += '</templates>'
self.template_files[fname] = _file.encode()
self.assertEqual(number_templates, nMod * nFilePerMod * nTemplatePerFile)
def test_performance_2500(self):
nMod, nFilePerMod, nTemplatePerFile = 50, 5, 10
self._sick_script(nMod, nFilePerMod, nTemplatePerFile)
before = datetime.now()
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
after = datetime.now()
self.assertLessEqual(after - before, timedelta(milliseconds=130))
whole_tree = etree.fromstring(contents)
self.assertEqual(len(whole_tree), nMod * nFilePerMod * nTemplatePerFile)
def test_performance_25000(self):
nMod, nFilePerMod, nTemplatePerFile = 50, 5, 100
self._sick_script(nMod, nFilePerMod, nTemplatePerFile)
before = datetime.now()
HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
after = datetime.now()
self.assertLessEqual(after - before, timedelta(milliseconds=1000))
+6 -145
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import collections
import copy
import datetime
import fnmatch
import logging
@@ -24,7 +23,7 @@ from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.modules.module import get_resource_from_path, get_resource_path
from odoo.osv import orm
from odoo.tools import config, graph, ConstantMapping, SKIPPED_ELEMENT_TYPES, pycompat
from odoo.tools import config, graph, ConstantMapping, pycompat, apply_inheritance_specs, locate_node
from odoo.tools.convert import _fix_multiple_roots
from odoo.tools.json import scriptsafe as json_scriptsafe
from odoo.tools.safe_eval import safe_eval
@@ -133,31 +132,7 @@ def get_view_arch_from_file(filename, xmlid):
_logger.warning("Could not find view arch definition in file '%s' for xmlid '%s'", filename, xmlid_search)
return None
def add_text_before(node, text):
""" Add text before ``node`` in its XML tree. """
if text is None:
return
prev = node.getprevious()
if prev is not None:
prev.tail = (prev.tail or "") + text
else:
parent = node.getparent()
parent.text = (parent.text or "") + text
def add_text_inside(node, text):
""" Add text inside ``node``. """
if text is None:
return
if len(node):
node[-1].tail = (node[-1].tail or "") + text
else:
node.text = (node.text or "") + text
def remove_element(node):
""" Remove ``node`` but not its tail, from its XML tree. """
add_text_before(node, node.tail)
node.tail = None
node.getparent().remove(node)
xpath_utils = etree.FunctionNamespace(None)
xpath_utils['hasclass'] = _hasclass
@@ -562,28 +537,7 @@ actual arch.
:param spec: a modifying node in an inheriting view
:return: a node in the source matching the spec
"""
if spec.tag == 'xpath':
nodes = etree.ETXPath(spec.get('expr'))(arch)
return nodes[0] if nodes else None
elif spec.tag == 'field':
# Only compare the field name: a field can be only once in a given view
# at a given level (and for multilevel expressions, we should use xpath
# inheritance spec anyway).
for node in arch.iter('field'):
if node.get('name') == spec.get('name'):
return node
return None
for node in arch.iter(spec.tag):
if isinstance(node, SKIPPED_ELEMENT_TYPES):
continue
if all(node.get(attr) == spec.get(attr) for attr in spec.attrib
if attr not in ('position', 'version')):
# Version spec should match parent's root element's version
if spec.get('version') and spec.get('version') != arch.get('version'):
return None
return node
return None
return locate_node(arch, spec)
def inherit_branding(self, specs_tree, view_id, root_id):
for node in specs_tree.iterchildren(tag=etree.Element):
@@ -613,103 +567,10 @@ actual arch.
"""
# Queue of specification nodes (i.e. nodes describing where and
# changes to apply to some parent architecture).
specs = [specs_tree]
def extract(spec):
"""
Utility function that locates a node given a specification, remove
it from the source and returns it.
"""
if len(spec):
self.raise_view_error(_("Invalid specification for moved nodes: '%s'") %
etree.tostring(spec), inherit_id)
to_extract = self.locate_node(source, spec)
if to_extract is not None:
remove_element(to_extract)
return to_extract
else:
self.raise_view_error(_("Element '%s' cannot be located in parent view") %
etree.tostring(spec), inherit_id)
while len(specs):
spec = specs.pop(0)
if isinstance(spec, SKIPPED_ELEMENT_TYPES):
continue
if spec.tag == 'data':
specs += [c for c in spec]
continue
node = self.locate_node(source, spec)
if node is not None:
pos = spec.get('position', 'inside')
if pos == 'replace':
for loc in spec.xpath(".//*[text()='$0']"):
loc.text = ''
loc.append(copy.deepcopy(node))
if node.getparent() is None:
source = copy.deepcopy(spec[0])
else:
for child in spec:
if child.get('position') == 'move':
child = extract(child)
node.addprevious(child)
node.getparent().remove(node)
elif pos == 'attributes':
for child in spec.getiterator('attribute'):
attribute = child.get('name')
value = child.text or ''
if child.get('add') or child.get('remove'):
assert not child.text
separator = child.get('separator', ',')
if separator == ' ':
separator = None # squash spaces
to_add = (
s for s in (s.strip() for s in child.get('add', '').split(separator))
if s
)
to_remove = {s.strip() for s in child.get('remove', '').split(separator)}
values = (s.strip() for s in node.get(attribute, '').split(separator))
value = (separator or ' ').join(itertools.chain(
(v for v in values if v not in to_remove),
to_add
))
if value:
node.set(attribute, value)
elif attribute in node.attrib:
del node.attrib[attribute]
elif pos == 'inside':
add_text_inside(node, spec.text)
for child in spec:
if child.get('position') == 'move':
child = extract(child)
node.append(child)
elif pos == 'after':
# add a sentinel element right after node, insert content of
# spec before the sentinel, then remove the sentinel element
sentinel = E.sentinel()
node.addnext(sentinel)
add_text_before(sentinel, spec.text)
for child in spec:
if child.get('position') == 'move':
child = extract(child)
sentinel.addprevious(child)
remove_element(sentinel)
elif pos == 'before':
add_text_before(node, spec.text)
for child in spec:
if child.get('position') == 'move':
child = extract(child)
node.addprevious(child)
else:
self.raise_view_error(_("Invalid position attribute: '%s'") % pos, inherit_id)
else:
attrs = ''.join([
' %s="%s"' % (attr, spec.get(attr))
for attr in spec.attrib
if attr != 'position'
])
tag = "<%s%s>" % (spec.tag, attrs)
self.raise_view_error(_("Element '%s' cannot be located in parent view") % tag, inherit_id)
try:
source = apply_inheritance_specs(source, specs_tree)
except ValueError as e:
self.raise_view_error(str(e), inherit_id)
return source
+1
View File
@@ -18,3 +18,4 @@ from .debugger import *
from .xml_utils import *
from .date_utils import *
from .convert import *
from .template_inheritance import *
+197
View File
@@ -0,0 +1,197 @@
from lxml import etree
from lxml.builder import E
import copy
import itertools
from odoo.tools.translate import _
from odoo.tools import SKIPPED_ELEMENT_TYPES
def add_text_before(node, text):
""" Add text before ``node`` in its XML tree. """
if text is None:
return
prev = node.getprevious()
if prev is not None:
prev.tail = (prev.tail or "") + text
else:
parent = node.getparent()
parent.text = (parent.text or "") + text
def add_text_inside(node, text):
""" Add text inside ``node``. """
if text is None:
return
if len(node):
node[-1].tail = (node[-1].tail or "") + text
else:
node.text = (node.text or "") + text
def remove_element(node):
""" Remove ``node`` but not its tail, from its XML tree. """
add_text_before(node, node.tail)
node.tail = None
node.getparent().remove(node)
def locate_node(arch, spec):
""" Locate a node in a source (parent) architecture.
Given a complete source (parent) architecture (i.e. the field
`arch` in a view), and a 'spec' node (a node in an inheriting
view that specifies the location in the source view of what
should be changed), return (if it exists) the node in the
source view matching the specification.
:param arch: a parent architecture to modify
:param spec: a modifying node in an inheriting view
:return: a node in the source matching the spec
"""
if spec.tag == 'xpath':
nodes = etree.ETXPath(spec.get('expr'))(arch)
return nodes[0] if nodes else None
elif spec.tag == 'field':
# Only compare the field name: a field can be only once in a given view
# at a given level (and for multilevel expressions, we should use xpath
# inheritance spec anyway).
for node in arch.iter('field'):
if node.get('name') == spec.get('name'):
return node
return None
for node in arch.iter(spec.tag):
if isinstance(node, SKIPPED_ELEMENT_TYPES):
continue
if all(node.get(attr) == spec.get(attr) for attr in spec.attrib
if attr not in ('position', 'version')):
# Version spec should match parent's root element's version
if spec.get('version') and spec.get('version') != arch.get('version'):
return None
return node
return None
def apply_inheritance_specs(source, specs_tree):
""" Apply an inheriting view (a descendant of the base view)
Apply to a source architecture all the spec nodes (i.e. nodes
describing where and what changes to apply to some parent
architecture) given by an inheriting view.
:param Element source: a parent architecture to modify
:return: a modified source where the specs are applied
:rtype: Element
"""
# Queue of specification nodes (i.e. nodes describing where and
# changes to apply to some parent architecture).
specs = specs_tree if isinstance(specs_tree, list) else [specs_tree]
def extract(spec):
"""
Utility function that locates a node given a specification, remove
it from the source and returns it.
"""
if len(spec):
raise ValueError(
_("Invalid specification for moved nodes: '%s'") %
etree.tostring(spec)
)
to_extract = locate_node(source, spec)
if to_extract is not None:
remove_element(to_extract)
return to_extract
else:
raise ValueError(
_("Element '%s' cannot be located in parent view") %
etree.tostring(spec)
)
while len(specs):
spec = specs.pop(0)
if isinstance(spec, SKIPPED_ELEMENT_TYPES):
continue
if spec.tag == 'data':
specs += [c for c in spec]
continue
node = locate_node(source, spec)
if node is not None:
pos = spec.get('position', 'inside')
if pos == 'replace':
for loc in spec.xpath(".//*[text()='$0']"):
loc.text = ''
loc.append(copy.deepcopy(node))
if node.getparent() is None:
source = copy.deepcopy(spec[0])
else:
for child in spec:
if child.get('position') == 'move':
child = extract(child)
node.addprevious(child)
node.getparent().remove(node)
elif pos == 'attributes':
for child in spec.getiterator('attribute'):
attribute = child.get('name')
value = child.text or ''
if child.get('add') or child.get('remove'):
assert not child.text
separator = child.get('separator', ',')
if separator == ' ':
separator = None # squash spaces
to_add = (
s for s in (s.strip() for s in child.get('add', '').split(separator))
if s
)
to_remove = {s.strip() for s in child.get('remove', '').split(separator)}
values = (s.strip() for s in node.get(attribute, '').split(separator))
value = (separator or ' ').join(itertools.chain(
(v for v in values if v not in to_remove),
to_add
))
if value:
node.set(attribute, value)
elif attribute in node.attrib:
del node.attrib[attribute]
elif pos == 'inside':
add_text_inside(node, spec.text)
for child in spec:
if child.get('position') == 'move':
child = extract(child)
node.append(child)
elif pos == 'after':
# add a sentinel element right after node, insert content of
# spec before the sentinel, then remove the sentinel element
sentinel = E.sentinel()
node.addnext(sentinel)
add_text_before(sentinel, spec.text)
for child in spec:
if child.get('position') == 'move':
child = extract(child)
sentinel.addprevious(child)
remove_element(sentinel)
elif pos == 'before':
add_text_before(node, spec.text)
for child in spec:
if child.get('position') == 'move':
child = extract(child)
node.addprevious(child)
else:
raise ValueError(
_("Invalid position attribute: '%s'") %
pos
)
else:
attrs = ''.join([
' %s="%s"' % (attr, spec.get(attr))
for attr in spec.attrib
if attr != 'position'
])
tag = "<%s%s>" % (spec.tag, attrs)
raise ValueError(
_("Element '%s' cannot be located in parent view") % tag
)
return source