diff options
| author | Lucas Perais (lpe) <lpe@odoo.com> | 2019-06-04 13:29:58 +0000 |
|---|---|---|
| committer | Vincent Schippefilt <vsc@odoo.com> | 2019-07-24 12:21:59 +0000 |
| commit | fc5878ecc668ee83cd1a374474f0b21bf6a51940 (patch) | |
| tree | b08e064a72f84d4fb14a0b5560ae1fd142c05563 | |
| parent | 8be4567ae8f6b0388206c091590278ef11a2df21 (diff) | |
[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>
| -rw-r--r-- | addons/web/controllers/main.py | 241 | ||||
| -rw-r--r-- | addons/web/models/ir_http.py | 5 | ||||
| -rw-r--r-- | addons/web/static/tests/services/notification_service_tests.js | 12 | ||||
| -rw-r--r-- | addons/web/tests/test_serving_base.py | 417 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_ui_view.py | 153 | ||||
| -rw-r--r-- | odoo/tools/__init__.py | 1 | ||||
| -rw-r--r-- | odoo/tools/template_inheritance.py | 197 |
7 files changed, 828 insertions, 198 deletions
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index d77a4ea9e7e..0a178431f2a 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -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)) diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 24b2ec551f7..5d1ac1aac11 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -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) diff --git a/addons/web/static/tests/services/notification_service_tests.js b/addons/web/static/tests/services/notification_service_tests.js index 303b38bbe3b..01fa2a9f8b0 100644 --- a/addons/web/static/tests/services/notification_service_tests.js +++ b/addons/web/static/tests/services/notification_service_tests.js @@ -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)')); diff --git a/addons/web/tests/test_serving_base.py b/addons/web/tests/test_serving_base.py index e1c6c7d5ab0..bda394c5152 100644 --- a/addons/web/tests/test_serving_base.py +++ b/addons/web/tests/test_serving_base.py @@ -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)) diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index a110361974a..09eb21a4ac6 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -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 diff --git a/odoo/tools/__init__.py b/odoo/tools/__init__.py index 2381f8c3472..4ac0af8ddfe 100644 --- a/odoo/tools/__init__.py +++ b/odoo/tools/__init__.py @@ -18,3 +18,4 @@ from .debugger import * from .xml_utils import * from .date_utils import * from .convert import * +from .template_inheritance import *
\ No newline at end of file diff --git a/odoo/tools/template_inheritance.py b/odoo/tools/template_inheritance.py new file mode 100644 index 00000000000..3b55dd08c4e --- /dev/null +++ b/odoo/tools/template_inheritance.py @@ -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 |
