[IMP] base/web: XML templates are added into the asset bundles.

XML files are now declared in python module manifests. During the qweb
't-call-asset' directive, assetbundle will fetch the declared xml files,
apply the inheritance (t-inherit) and create a javascript service (for
eg: 'web.assets_backend.bundle.xml') which is added at the end of the
*.js mimifier file.

When the debug mode is activated, comments are added in the template
indicating which file the template comes from as well as the
inheritances applied to it.

****

JavaScript:

assets.js (module @web/core/assets) takes care of loading libraries,
javascripts and styles.
`loadJS(url)` (loads the javascript and returns a resolved promise when
the templates are also loaded via the '*.bundle.xml' service)
`loadCSS(url)` (loads the style a resolved promise when the file is
loaded)
`loadXML(xml, app=assets.defaultApp)` (load template into
application/owl, used by the `*.bundle.xml` services)
`getBundle(bundleName)` (get the bundle descriptor)
`loadBundle(desc)` (load the files and bundle from a descriptor)

templates (XML element content all owl templates)

A new `ready(serviceName)` method on boot.js lets you know when a
service is loaded are the require.

The xmlDependencies attribute no longer exists.

Python:

The xmls taken into account by assetbundle.py, applying `t-inherit`
inheritances and adding an `name_of_the_bundle.bundle.xml` service in
the generated JavaScript file.

****

Every manifest changes is into the next commit, except 'web_tour' in
this current commit as example.

Part-of: odoo/odoo#95500
This commit is contained in:
Gorash
2022-09-14 20:25:01 +02:00
parent ed2b117f5b
commit 5410b7c238
60 changed files with 1037 additions and 1277 deletions
-3
View File
@@ -3,7 +3,6 @@
import odoo
from odoo import models
from odoo.http import request
from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers
class IrHttp(models.AbstractModel):
@@ -14,8 +13,6 @@ class IrHttp(models.AbstractModel):
result = super(IrHttp, self).session_info()
if self.env.user._is_internal():
result['notification_type'] = user.notification_type
assets_discuss_public_hash = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle='mail.assets_discuss_public')
result['cache_hashes']['assets_discuss_public'] = assets_discuss_public_hash
guest = self.env.context.get('guest')
if not request.session.uid and guest:
user_context = {'lang': guest.lang}
@@ -8,7 +8,6 @@ import { DiscussPublicViewContainer } from '@mail/components/discuss_public_view
import { PopoverManagerContainer } from '@mail/components/popover_manager_container/popover_manager_container';
import { messagingService } from '@mail/services/messaging_service';
import { processTemplates } from '@web/core/assets';
import { MainComponentsContainer } from '@web/core/main_components_container';
import { registry } from '@web/core/registry';
import { makeEnv, startServices } from '@web/env';
@@ -21,6 +20,7 @@ import {
mapLegacyEnvToWowlEnv,
} from '@web/legacy/utils';
import { session } from '@web/session';
import { templates } from '@web/core/assets';
import * as AbstractService from 'web.AbstractService';
import * as legacyEnv from 'web.env';
@@ -69,10 +69,7 @@ Component.env = legacyEnv;
isReady: false,
});
const env = makeEnv();
const [, templates] = await Promise.all([
startServices(env),
odoo.loadTemplatesPromise.then(processTemplates),
]);
await startServices(env);
mapLegacyEnvToWowlEnv(Component.env, env);
odoo.isReady = true;
await mount(MainComponentsContainer, document.body, { env, templates, dev: env.debug });
@@ -15,7 +15,6 @@
csrf_token: "<t t-out="request.csrf_token(None)"/>",
debug: "<t t-out="debug"/>",
};
odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.assets_discuss_public}?bundle=mail.assets_discuss_public`).then(doc => doc.text());
{
const { user_context, cache_hashes } = odoo.__session_info__;
fetch(`/web/webclient/translations/${cache_hashes.translations}?lang=${user_context.lang}`);
@@ -9,7 +9,6 @@ from odoo.http import request
from odoo.exceptions import AccessError, MissingError
from odoo.addons.portal.controllers import portal
from odoo.addons.portal.controllers.portal import pager as portal_pager
from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers
class CustomerPortal(portal.CustomerPortal):
@@ -91,11 +90,9 @@ class CustomerPortal(portal.CustomerPortal):
session_info = request.env['ir.http'].session_info()
user_context = dict(request.env.context) if request.session.uid else {}
mods = conf.server_wide_modules or []
qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="mrp_subcontracting.assets_qweb")
lang = user_context.get("lang")
translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang)
cache_hashes = {
"qweb": qweb_checksum,
"translations": translation_hash,
}
production_company = picking.company_id
@@ -70,7 +70,6 @@
<t t-set="head_subcontracting_portal">
<script type="text/javascript">
odoo.__session_info__ = <t t-out="json.dumps(session_info)"/>;
odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.qweb}?bundle=mrp_subcontracting.assets_qweb`).then(doc => doc.text());
</script>
<base target="_parent"/>
<t t-call-assets="web.assets_common" t-js="false"/>
@@ -36,7 +36,6 @@
// Prevent the menu_service to load anything. In an ideal world, POS assets would only contain
// what is genuinely necessary, and not the whole backend.
odoo.loadMenusPromise = Promise.resolve();
odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.qweb}?bundle=web.assets_qweb`).then(doc => doc.text());
</script>
<t t-call="point_of_sale.assets_common"/>
-4
View File
@@ -12,8 +12,6 @@ from odoo.tools import groupby as groupbyelem
from odoo.osv.expression import OR, AND
from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers
class ProjectCustomerPortal(CustomerPortal):
@@ -135,7 +133,6 @@ class ProjectCustomerPortal(CustomerPortal):
session_info = request.env['ir.http'].session_info()
user_context = dict(request.env.context) if request.session.uid else {}
mods = conf.server_wide_modules or []
qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="project.assets_qweb")
if request.env.lang:
lang = request.env.lang
session_info['user_context']['lang'] = lang
@@ -144,7 +141,6 @@ class ProjectCustomerPortal(CustomerPortal):
lang = user_context.get("lang")
translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang)
cache_hashes = {
"qweb": qweb_checksum,
"translations": translation_hash,
}
@@ -21,7 +21,6 @@
// Prevent the menu_service to load anything. In an ideal world, Project Sharing assets would only contain
// what is genuinely necessary, and not the whole backend.
odoo.loadMenusPromise = Promise.resolve();
odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.qweb}?bundle=project.assets_qweb`).then(doc => doc.text());
</script>
<base target="_parent"/>
<t t-call-assets="web.assets_common" t-js="false"/>
+1
View File
@@ -406,6 +406,7 @@ This module provides the core of the Odoo Web Client.
'web/static/lib/clipboard/clipboard.js',
'web/static/lib/jSignature/jSignatureCustom.js',
'web/static/lib/qweb/qweb2.js',
'web/static/src/legacy/js/assets.js',
'web/static/src/legacy/js/libs/autocomplete.js',
'web/static/src/legacy/js/libs/bootstrap.js',
'web/static/src/legacy/js/libs/content-disposition.js',
-1
View File
@@ -32,7 +32,6 @@ _MOVED_TO_MAP = {
'GroupExportXlsxWriter': export,
'GroupsTreeNode': export,
'Home': home,
'HomeStaticTemplateHelpers': utils,
'none_values_filtered': export,
'OPERATOR_MAPPING': export,
'ReportController': report,
-209
View File
@@ -215,212 +215,3 @@ def _local_web_translations(trans_file):
if x.id and x.string and "openerp-web" in x.auto_comments:
messages.append({'id': x.id, 'string': x.string})
return messages
class HomeStaticTemplateHelpers:
r"""
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
COMMENT_PATTERN = r'Modified by [\s\w\-.]+ from [\s\w\-.]+'
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
"""
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
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]
dotted_names = template_name.split('.', 1)
if len(dotted_names) > 1 and dotted_names[0] == addon:
template_name = dotted_names[1]
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])
xpaths = list(template_tree)
# owl chokes on comments, disable debug comments for now
# pylint: disable=W0125
if False: # 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)))
elif inherit_mode == self.PRIMARY_MODE:
parent_tree.tag = template_tree.tag
inherited_template = apply_inheritance_specs(parent_tree, xpaths)
if inherit_mode == self.PRIMARY_MODE: # New template_tree: A' = B(A)
for attr_name, attr_val in template_tree.attrib.items():
if attr_name not in ('t-inherit', 't-inherit-mode'):
inherited_template.set(attr_name, attr_val)
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(self.COMMENT_PATTERN, comment.text.strip()):
comment.getparent().remove(comment)
def _read_addon_file(self, path_or_url):
"""Read the content of a file or an ``ir.attachment`` record given by
``path_or_url``.
:param str path_or_url:
:returns: bytes
:raises FileNotFoundError: if the path does not match a module file
or an attachment
"""
try:
with file_open(path_or_url, 'rb') as fp:
contents = fp.read()
except FileNotFoundError as e:
attachment = request.env['ir.attachment'].sudo().search([
('url', '=', path_or_url),
('type', '=', 'binary'),
], limit=1)
if attachment:
contents = attachment.raw
else:
raise e
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: (bytes, str)
"""
checksum = hashlib.new('sha512') # sha512/256
if not file_dict:
return b'', 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:
self._compute_xml_tree(addon, fname, contents)
if root is None:
root = etree.Element('templates')
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 b'', checksum.hexdigest()[:64]
def _get_asset_paths(self, bundle):
"""Proxy for ir_asset._get_asset_paths
Useful to make 'self' testable.
"""
return request.env['ir.asset']._get_asset_paths(addons=self.addons, bundle=bundle, xml=True)
def _get_qweb_templates(self, bundle):
"""One and only entry point that gets and evaluates static qweb templates
:rtype: (str, str)
"""
xml_paths = defaultdict(list)
# group paths by module, keeping them in order
for path, addon, _ in self._get_asset_paths(bundle):
addon_paths = xml_paths[addon]
if path not in addon_paths:
addon_paths.append(path)
content, checksum = self._concat_xml(xml_paths)
return content, checksum
@classmethod
def get_qweb_templates_checksum(cls, addons=None, db=None, debug=False, bundle=None):
return cls(addons, db, checksum_only=True, debug=debug)._get_qweb_templates(bundle)[1]
@classmethod
def get_qweb_templates(cls, addons=None, db=None, debug=False, bundle=None):
return cls(addons, db, debug=debug)._get_qweb_templates(bundle)[0]
+14 -19
View File
@@ -17,7 +17,7 @@ from odoo.modules import get_manifest, get_resource_path
from odoo.http import request
from odoo.tools import lazy
from odoo.tools.misc import file_open
from .utils import HomeStaticTemplateHelpers, _local_web_translations
from .utils import _local_web_translations
_logger = logging.getLogger(__name__)
@@ -53,17 +53,6 @@ class WebClient(http.Controller):
('Cache-Control', f'max-age={http.STATIC_CACHE}'),
])
@http.route('/web/webclient/qweb/<string:unique>', type='http', auth="none", cors="*")
def qweb(self, unique, mods=None, db=None, bundle=None):
if not request.db and mods is None:
mods = odoo.conf.server_wide_modules or []
content = HomeStaticTemplateHelpers.get_qweb_templates(mods, db, debug=request.session.debug, bundle=bundle)
return request.make_response(content, [
('Content-Type', 'text/xml'),
('Cache-Control', f'public, max-age={http.STATIC_CACHE_LONG}')
])
@http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
def bootstrap_translations(self, mods=None):
""" Load local translations from *.po files, as a temporary solution
@@ -139,14 +128,20 @@ class WebClient(http.Controller):
def benchmarks(self, mod=None, **kwargs):
return request.render('web.benchmark_suite')
@http.route('/web/bundle/<string:bundle_name>', auth="user", methods=["GET"])
def bundle(self, bundle_name):
@http.route('/web/bundle/<string:bundle_name>', auth="public", methods=["GET"])
def bundle(self, bundle_name, **bundle_params):
"""
Request the definition of a bundle, including its javascript and css bundled assets
"""
files = request.env["ir.qweb"]._get_asset_nodes(bundle_name, debug=request.session.debug, js=True, css=True)
data = json.dumps([{
if 'lang' in bundle_params:
request.update_context(lang=bundle_params['lang'])
debug = bundle_params.get('debug', request.session.debug)
files = request.env["ir.qweb"]._get_asset_nodes(bundle_name, debug=debug, js=True, css=True)
data = [{
"type": tag,
"src": attrs.get("src") or attrs.get('href'),
} for tag, attrs, _ in files])
return request.make_response(data, [('Content-Type', 'application/json')])
"src": attrs.get("src") or attrs.get("data-src") or attrs.get('href'),
"content": content,
} for tag, attrs, content in files]
return request.make_json_response(data)
+10 -3
View File
@@ -9,7 +9,6 @@ from odoo import api, http, models
from odoo.http import request
from odoo.tools import file_open, image_process, ustr
from odoo.tools.misc import str2bool
from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers
_logger = logging.getLogger(__name__)
@@ -110,7 +109,12 @@ class Http(models.AbstractModel):
) if session_uid else None,
},
"currencies": self.sudo().get_currencies(),
'bundle_params': {
'lang': request.session.context['lang'],
},
}
if request.session.debug:
session_info['bundle_params']['debug'] = request.session.debug
if self.env.user.has_group('base.group_user'):
# the following is only useful in the context of a webclient bootstrapping
# but is still included in some other calls (e.g. '/web/session/authenticate')
@@ -118,13 +122,11 @@ class Http(models.AbstractModel):
# with access to the backend ('internal'-type users)
if request.db:
mods = list(request.registry._init_modules) + mods
qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="web.assets_qweb")
menus = request.env['ir.ui.menu'].load_menus(request.session.debug)
ordered_menus = {str(k): v for k, v in menus.items()}
menu_json_utf8 = json.dumps(ordered_menus, default=ustr, sort_keys=True).encode()
session_info['cache_hashes'].update({
"load_menus": hashlib.sha512(menu_json_utf8).hexdigest()[:64], # sha512/256
"qweb": qweb_checksum,
})
session_info.update({
# current_company should be default_company
@@ -157,7 +159,12 @@ class Http(models.AbstractModel):
'profile_collectors': request.session.profile_collectors,
'profile_params': request.session.profile_params,
'show_effect': bool(request.env['ir.config_parameter'].sudo().get_param('base_setup.show_effect')),
'bundle_params': {
'lang': request.session.context['lang'],
},
}
if request.session.debug:
session_info['bundle_params']['debug'] = request.session.debug
if session_uid:
version_info = odoo.service.common.exp_version()
session_info.update({
+106 -85
View File
@@ -111,6 +111,93 @@
return !!job.error;
});
},
processJobs: function () {
var job;
function processJob(job) {
var require = makeRequire(job);
var jobExec;
function onError(e) {
job.error = e;
console.error(`Error while loading ${job.name}: ${e.message}`, e);
}
var def = new Promise(function (resolve) {
try {
jobExec = job.factory.call(null, require);
jobs.splice(jobs.indexOf(job), 1);
} catch (e) {
onError(e);
}
if (!job.error) {
Promise.resolve(jobExec)
.then(function (data) {
services[job.name] = data;
resolve();
odoo.__DEBUG__.processJobs();
})
.guardedCatch(function (e) {
job.rejected = e || true;
jobs.push(job);
})
.catch(function (e) {
if (e instanceof Error) {
onError(e);
}
resolve();
});
} else {
resolve();
}
});
jobPromises.push(def);
def.then(job.resolve);
}
function isReady(job) {
return (
!job.error &&
!job.rejected &&
job.factory.deps.every(function (name) {
return name in services;
})
);
}
function makeRequire(job) {
var deps = {};
Object.keys(services)
.filter(function (item) {
return job.deps.indexOf(item) >= 0;
})
.forEach(function (key) {
deps[key] = services[key];
});
return function require(name) {
if (!(name in deps)) {
console.error("Undefined dependency: ", name);
}
return deps[name];
};
}
while (jobs.length) {
job = undefined;
for (var i = 0; i < jobs.length; i++) {
if (isReady(jobs[i])) {
job = jobs[i];
break;
}
}
if (!job) {
break;
}
processJob(job);
}
return services;
},
factories: factories,
services: services,
};
@@ -147,17 +234,21 @@
factory.deps = deps;
factories[name] = factory;
let promiseResolve;
const promise = new Promise(resolve => {promiseResolve = resolve;});
jobs.push({
name: name,
factory: factory,
deps: deps,
resolve: promiseResolve,
promise: promise,
});
deps.forEach(function (dep) {
jobDeps.push({ from: dep, to: name });
});
this.processJobs(jobs, services);
odoo.__DEBUG__.processJobs();
};
odoo.log = function () {
var missing = [];
@@ -271,91 +362,21 @@
};
didLogInfoResolve(true);
};
odoo.processJobs = function (jobs, services) {
var job;
function processJob(job) {
var require = makeRequire(job);
var jobExec;
function onError(e) {
job.error = e;
console.error(`Error while loading ${job.name}: ${e.message}`, e);
}
var def = new Promise(function (resolve) {
try {
jobExec = job.factory.call(null, require);
jobs.splice(jobs.indexOf(job), 1);
} catch (e) {
onError(e);
}
if (!job.error) {
Promise.resolve(jobExec)
.then(function (data) {
services[job.name] = data;
resolve();
odoo.processJobs(jobs, services);
})
.guardedCatch(function (e) {
job.rejected = e || true;
jobs.push(job);
})
.catch(function (e) {
if (e instanceof Error) {
onError(e);
}
resolve();
});
} else {
resolve();
}
});
jobPromises.push(def);
/**
* Returns a resolved promise when the targeted services are loaded.
* If no service is found the promise is used directly.
*
* @param {string|RegExp} serviceName name of the service to expect
* or regular expression matching the service.
* @returns {Promise<number>} resolved when the services ares
* loaded. The value is equal to the number of services found.
*/
odoo.ready = async function (serviceName) {
function match (name) {
return typeof serviceName === 'string' ? name === serviceName : serviceName.test(name);
}
function isReady(job) {
return (
!job.error &&
!job.rejected &&
job.factory.deps.every(function (name) {
return name in services;
})
);
}
function makeRequire(job) {
var deps = {};
Object.keys(services)
.filter(function (item) {
return job.deps.indexOf(item) >= 0;
})
.forEach(function (key) {
deps[key] = services[key];
});
return function require(name) {
if (!(name in deps)) {
console.error("Undefined dependency: ", name);
}
return deps[name];
};
}
while (jobs.length) {
job = undefined;
for (var i = 0; i < jobs.length; i++) {
if (isReady(jobs[i])) {
job = jobs[i];
break;
}
}
if (!job) {
break;
}
processJob(job);
}
return services;
await Promise.all(jobs.filter(job => match(job.name)).map(job => job.promise));
return Object.keys(factories).filter(match).length;
};
// Automatically log errors detected when loading modules
+175 -95
View File
@@ -3,16 +3,16 @@
import { memoize } from "./utils/functions";
import { browser } from "./browser/browser";
import { registry } from "./registry";
class AssetsLoadingError extends Error {}
import { session } from "@web/session";
/**
* An object describing a bundle to load
* @typedef {Object} BundleInfo
* @property {'script'|'link'} [type] the type of file in this bundle
* @property {string} [src] the url of the file for this bundle, for this type of file
* @example `[{"type": "script", "src": "/web/assets/266-d34b0b4/documents_spreadsheet.o_spreadsheet.min.js"}]`
* This export is done only in order to modify the behavior of the exported
* functions. This is done in order to be able to make a test environment.
* Modules should only use the methods exported below.
*/
export const assets = {};
class AssetsLoadingError extends Error {}
/**
* Loads the given url inside a script tag.
@@ -20,7 +20,7 @@ class AssetsLoadingError extends Error {}
* @param {string} url the url of the script
* @returns {Promise<true>} resolved when the script has been loaded
*/
export const loadJS = memoize(function loadJS(url) {
assets.loadJS = memoize(function loadJS(url) {
if (document.querySelector(`script[src="${url}"]`)) {
// Already in the DOM and wasn't loaded through this function
// Unfortunately there is no way to check whether a script has loaded
@@ -28,6 +28,7 @@ export const loadJS = memoize(function loadJS(url) {
// so we assume it is.
return Promise.resolve();
}
const scriptEl = document.createElement("script");
scriptEl.type = "text/javascript";
scriptEl.src = url;
@@ -39,13 +40,14 @@ export const loadJS = memoize(function loadJS(url) {
});
});
});
/**
* Loads the given url as a stylesheet.
*
* @param {string} url the url of the stylesheet
* @returns {Promise<true>} resolved when the stylesheet has been loaded
*/
export const loadCSS = memoize(function loadCSS(url) {
assets.loadCSS = memoize(function loadCSS(url) {
if (document.querySelector(`link[href="${url}"]`)) {
// Already in the DOM and wasn't loaded through this function
// Unfortunately there is no way to check whether a link has loaded
@@ -65,110 +67,187 @@ export const loadCSS = memoize(function loadCSS(url) {
});
});
});
/**
* Loads the qweb templates from a given bundle name.
* TODO: merge this into loadBundleDefinition?
*
* @param {string} bundle the name of the bundle as declared in the manifest.
* @returns {Promise<XMLDocument|"">} A Promise of an XML document containing
* the owl templates or an empty string if the bundle has none.
*/
export const fetchAndProcessTemplates = memoize(async function fetchAndProcessTemplates(bundle) {
// TODO: quid of the "unique" in the URL? We can't have one cache_hash
// for each and every bundle I'm guessing.
const bundleURL = `/web/webclient/qweb/${Date.now()}?bundle=${bundle}`;
const templates = await (await browser.fetch(bundleURL)).text();
if (!templates) {
return "";
}
return processTemplates(templates);
});
/**
* Loads the content definition of a bundle.
*
* @param {string} name the bundleName of the bundle as declared in the manifest.
* @returns {Promise<BundleInfo[]>} A promise of the content definition of the bundle
* Container dom containing all the owl templates that have been loaded.
* This can be imported by the modules in order to use it when loading the
* application and the components.
*/
const loadBundleDefinition = memoize(async function (bundleName) {
const request = await browser.fetch(`/web/bundle/${bundleName}`);
return await request.json();
});
const bundlesCache = {};
export const templates = new DOMParser().parseFromString("<odoo/>", "text/xml");
let defaultApp;
/**
* Loads a bundle.
* Loads the given xml template.
*
* @param {string} name the name of the bundle to load
* @param {owl.App} [app] the app in which the bundle's templates should be
* loaded. Defaults to the app that's written on the function itself, this
* is considered the main app, and should be written on the function by the
* code that bootstraps the app. In most cases, this will be the webclient,
* and is set in start.js
* @returns {Promise<void>} a promise that is resolved after the bundle has been
* loaded.
* @param {string} xml the string defining the templates
* @param {App} [app=defaultApp] optional owl App instance (default value
* can be changed with setLoadXmlDefaultApp method)
* @returns {Promise<true>} resolved when the template xml has been loaded
*/
export async function loadBundle(name, app = loadBundle.app) {
if (!bundlesCache[name]) {
bundlesCache[name] = Promise.all([
fetchAndProcessTemplates(name).then((templates) => app.addTemplates(templates, app)),
loadBundleDefinition(name).then((bundleInfo) =>
Promise.all([
...bundleInfo.filter((i) => i.type === "script").map((i) => loadJS(i.src)),
...bundleInfo.filter((i) => i.type === "link").map((i) => loadCSS(i.src)),
])
),
]).then(() => {});
assets.loadXML = function loadXML (xml, app=defaultApp) {
const doc = new DOMParser().parseFromString(xml, "text/xml");
if (doc.querySelector('parsererror')) {
throw doc.querySelector('parsererror div').textContent.split(':')[0];
}
return bundlesCache[name];
}
/**
* Process the qweb templates to obtain only the owl templates. This function
* does NOT register the templates into Owl.
*
* @param {string} templates An xml string describing templates
* @returns {XMLDocument} An xml document containing only the owl templates
*/
export function processTemplates(templates) {
const doc = new DOMParser().parseFromString(templates, "text/xml");
// as we currently have two qweb engines (owl and legacy), owl templates are
// flagged with attribute `owl="1"`. The following lines removes the "owl"
// attribute from the templates, so that it doesn't appear in the DOM. We
// also remove the non-owl templates, as those shouldn't be loaded in the
// owl application, and will be loaded separately.
for (const template of [...doc.querySelector("templates").children]) {
if (template.hasAttribute("owl")) {
template.removeAttribute("owl");
for (const element of doc.querySelectorAll("templates > [t-name][owl]")) {
element.removeAttribute("owl");
const name = element.getAttribute('t-name');
const previous = templates.querySelector(`[t-name="${name}"]`);
if (previous) {
console.debug('Override template: ' + name);
previous.replaceWith(element);
} else {
template.remove();
templates.documentElement.appendChild(element);
}
}
return doc;
if (app || defaultApp) {
console.debug('Add templates in Owl app.');
app.addTemplates(templates, app || defaultApp);
} else {
console.debug('Add templates on window Owl container.');
}
};
/**
* Update the default app to load templates.
*
* @param {App} app owl App instance
*/
export function setLoadXmlDefaultApp(app) {
defaultApp = app;
}
/**
* Renders a public asset template and loads the libraries defined inside of it.
* Only loads js and css, template declarations will be ignored. Only loads
* scripts and styles that are defined in script src and link href, ignores
* inline scripts and styles.
* Get the files information as descriptor object from a public asset template.
*
* @deprecated
* @param {string} xmlid The xmlid of the template that defines the public asset
* @param {ORM} orm An ORM object capable of calling methods on models
* @returns {Promise<void>} Resolved when the contents of the asset is loaded
* @param {string} bundleName Name of the bundle containing the list of files
* @returns {Promise<{cssLibs, cssContents, jsLibs, jsContents}>}
*/
export const loadPublicAsset = memoize(async function loadPublicAsset(xmlid, orm) {
const xml = await orm.call("ir.ui.view", "render_public_asset", [xmlid]);
const doc = new DOMParser().parseFromString(`<xml>${xml}</xml>`, "text/xml");
return Promise.all([
...[...doc.querySelectorAll("link[href]")].map((el) => loadCSS(el.getAttribute("href"))),
...[...doc.querySelectorAll("script[src]")].map((el) => loadJS(el.getAttribute("src"))),
]);
assets.getBundle = memoize(async function getBundle(bundleName) {
const url = new URL(`/web/bundle/${bundleName}`, location.origin);
for (const [key, value] of Object.entries(session.bundle_params || {})) {
url.searchParams.set(key, value);
}
const response = await browser.fetch(url.href);
const json = await response.json();
const assets = {
cssLibs: [],
cssContents: [],
jsLibs: [],
jsContents: [],
};
for (const key in json) {
const file = json[key];
if (file.type === 'link') {
assets.cssLibs.push(file.src);
} else if (file.type === 'style') {
assets.cssContents.push(file.content);
} else {
if (file.src) {
assets.jsLibs.push(file.src);
} else {
assets.jsContents.push(file.content);
}
}
}
return assets;
});
/**
* Loads the given js/css libraries and asset bundles. Note that no library or
* asset will be loaded if it was already done before.
*
* @param {Object} desc
* @param {Array<string|string[]>} [desc.assetLibs=[]]
* The list of assets to load. Each list item may be a string (the xmlID
* of the asset to load) or a list of strings. The first level is loaded
* sequentially (so use this if the order matters) while the assets in
* inner lists are loaded in parallel (use this for efficiency but only
* if the order does not matter, should rarely be the case for assets).
* @param {string[]} [desc.cssLibs=[]]
* The list of CSS files to load. They will all be loaded in parallel but
* put in the DOM in the given order (only the order in the DOM is used
* to determine priority of CSS rules, not loaded time).
* @param {Array<string|string[]>} [desc.jsLibs=[]]
* The list of JS files to load. Each list item may be a string (the URL
* of the file to load) or a list of strings. The first level is loaded
* sequentially (so use this if the order matters) while the files in inner
* lists are loaded in parallel (use this for efficiency but only
* if the order does not matter).
* @param {string[]} [desc.cssContents=[]]
* List of inline styles to add after loading the CSS files.
* @param {string[]} [desc.jsContents=[]]
* List of inline scripts to add after loading the JS files.
*
* @returns {Promise}
*/
assets.loadBundle = memoize(async function loadBundle(desc) {
// Load css in parallel
const promiseCSS = Promise.all((desc.cssLibs || []).map(assets.loadCSS)).then(() => {
if (desc.cssContents && desc.cssContents.length) {
const style = document.createElement("style");
style.textContent = desc.cssContents.join('\n');
document.head.appendChild(style);
}
});
// Load JavaScript (don't wait for the css loading)
for (const urlData of desc.jsLibs || []) {
if (typeof urlData === 'string') {
// serial loading
await assets.loadJS(urlData);
// Wait template if the JavaScript come from bundle.
const bundle = urlData.match(/\/web\/assets\/.*\/([^\/]+?)(\.min)?\.js/);
if (bundle) {
await odoo.ready(bundle[1] + '.bundle.xml');
}
} else {
// parallel loading
await Promise.all(urlData.map(loadJS));
}
}
if (desc.jsContents && desc.jsContents.length) {
const script = document.createElement("script");
script.type = "text/javascript";
script.textContent = desc.jsContents.join('\n');
document.head.appendChild(script);
}
// Wait for the scc loading to be completed before loading the other bundle
await promiseCSS;
// Load other desc
for (const bundleName of desc.assetLibs || []) {
if (typeof bundleName === 'string') {
// serial loading
const desc = await assets.getBundle(bundleName);
await assets.loadBundle(desc);
} else {
// parallel loading
await Promise.all(bundleName.map(async bundleName => {
const desc = await assets.getBundle(bundleName);
return assets.loadBundle(desc);
}));
}
}
});
export const loadJS = function (url) {
return assets.loadJS(url);
}
export const loadCSS = function (url) {
return assets.loadCSS(url);
}
export const loadXML = function (xml, app=defaultApp) {
return assets.loadXML(xml, app=app);
}
export const getBundle = function (bundleName) {
return assets.getBundle(bundleName);
}
export const loadBundle = function (desc) {
return assets.loadBundle(desc);
}
const { Component, xml, onWillStart } = owl;
/**
* Utility component that loads an asset bundle before instanciating a component
@@ -176,7 +255,8 @@ const { Component, xml, onWillStart } = owl;
export class LazyComponent extends Component {
setup() {
onWillStart(async () => {
await loadBundle(this.props.bundle);
const bundle = await getBundle(this.props.bundle);
await loadBundle(bundle);
this.Component = registry.category("lazy_components").get(this.props.Component);
});
}
+23
View File
@@ -0,0 +1,23 @@
odoo.define('web.assets.qweb', function (require) {
"use strict";
const {assets} = require('@web/core/assets');
const loadXML = assets.loadXML;
assets.loadXML = function (xml, app) {
loadXML(xml, app);
const doc = new DOMParser().parseFromString(xml, "text/xml");
const qwebTemplates = document.createElement("templates");
for (const element of doc.querySelectorAll("templates > [t-name]:not([owl]), templates > [t-extend]:not([owl])")) {
qwebTemplates.appendChild(element);
}
// don't use require to apply the patch before the first template loading.
odoo.ready('web.core').then(function () {
const core = odoo.__DEBUG__.services['web.core'];
core.qweb.add_template(qwebTemplates);
});
}
});
@@ -2,7 +2,6 @@ odoo.define('web.ajax', function (require) {
"use strict";
var config = require('web.config');
var concurrency = require('web.concurrency');
var core = require('web.core');
const {Markup} = require('web.utils');
var time = require('web.time');
@@ -137,90 +136,6 @@ function rpc(url, params, settings) {
return jsonRpc(url, 'call', params, settings);
}
/**
* Load css asynchronously: fetch it from the url parameter and add a link tag
* to <head>.
* If the url has already been requested and loaded, the promise will resolve
* immediately.
*
* @param {String} url of the css to be fetched
* @returns {Promise} resolved when the css has been loaded.
*/
var loadCSS = (function () {
var urlDefs = {};
return function loadCSS(url) {
if (url in urlDefs) {
// nothing to do here
} else if ($('link[href="' + url + '"]').length) {
// the link is already in the DOM, the promise can be resolved
urlDefs[url] = Promise.resolve();
} else {
var $link = $('<link>', {
'href': url,
'rel': 'stylesheet',
'type': 'text/css'
});
urlDefs[url] = new Promise(function (resolve, reject) {
$link.on('load', function () {
resolve();
}).on('error', function () {
reject(new Error("Couldn't load css dependency: " + $link[0].href));
});
});
$('head').append($link);
}
return urlDefs[url];
};
})();
var loadJS = (function () {
var dependenciesPromise = {};
var load = function loadJS(url) {
// Check the DOM to see if a script with the specified url is already there
var alreadyRequired = ($('script[src="' + url + '"]').length > 0);
// If loadJS was already called with the same URL, it will have a registered promise indicating if
// the script has been fully loaded. If not, the promise has to be initialized.
// This is initialized as already resolved if the script was already there without the need of loadJS.
if (url in dependenciesPromise) {
return dependenciesPromise[url];
}
var scriptLoadedPromise = new Promise(function (resolve, reject) {
if (alreadyRequired) {
resolve();
} else {
// Get the script associated promise and returns it after initializing the script if needed. The
// promise is marked to be resolved on script load and rejected on script error.
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = url;
script.onload = script.onreadystatechange = function() {
if ((script.readyState && script.readyState !== "loaded" && script.readyState !== "complete") || script.onload_done) {
return;
}
script.onload_done = true;
resolve(url);
};
script.onerror = function () {
console.error("Error loading file", script.src);
reject(url);
};
var head = document.head || document.getElementsByTagName('head')[0];
head.appendChild(script);
}
});
dependenciesPromise[url] = scriptLoadedPromise;
return scriptLoadedPromise;
};
return load;
})();
/**
* Cooperative file download implementation, for ajaxy APIs.
*
@@ -335,87 +250,6 @@ function post (controller_url, data) {
});
}
/**
* Loads an XML file according to the given URL and adds its associated qweb
* templates to the given qweb engine. The function can also be used to get
* the promise which indicates when all the calls to the function are finished.
*
* Note: "all the calls" = the calls that happened before the current no-args
* one + the calls that will happen after but when the previous ones are not
* finished yet.
*
* @param {string} [url] - an URL where to find qweb templates
* @param {QWeb} [qweb] - the engine to which the templates need to be added
* @returns {Promise}
* If no argument is given to the function, the promise's state
* indicates if "all the calls" are finished (see main description).
* Otherwise, it indicates when the templates associated to the given
* url have been loaded.
*/
var loadXML = (function () {
// Some "static" variables associated to the loadXML function
var isLoading = false;
var loadingsData = [];
var seenURLs = [];
return function (url, qweb) {
function _load() {
isLoading = true;
if (loadingsData.length) {
// There is something to load, load it, resolve the associated
// promise then start loading the next one
var loadingData = loadingsData[0];
loadingData.qweb.add_template(loadingData.url, function () {
// Remove from array only now so that multiple calls to
// loadXML with the same URL returns the right promise
loadingsData.shift();
loadingData.resolve();
_load();
});
} else {
// There is nothing to load anymore, so resolve the
// "all the calls" promise
isLoading = false;
}
}
// If no argument, simply returns the promise which indicates when
// "all the calls" are finished
if (!url || !qweb) {
return Promise.resolve();
}
// If the given URL has already been seen, do nothing but returning the
// associated promise
if (_.contains(seenURLs, url)) {
var oldLoadingData = _.findWhere(loadingsData, {url: url});
return oldLoadingData ? oldLoadingData.def : Promise.resolve();
}
seenURLs.push(url);
// Add the information about the new data to load: the url, the qweb
// engine and the associated promise
var newLoadingData = {
url: url,
qweb: qweb,
};
newLoadingData.def = new Promise(function (resolve, reject) {
newLoadingData.resolve = resolve;
newLoadingData.reject = reject;
});
loadingsData.push(newLoadingData);
// If not already started, start the loading loop (reinitialize the
// "all the calls" promise to an unresolved state)
if (!isLoading) {
_load();
}
// Return the promise associated to the new given URL
return newLoadingData.def;
};
})();
/**
* Loads a template file according to the given xmlId.
@@ -478,92 +312,10 @@ var loadAsset = (function () {
return load;
})();
/**
* Loads the given js/css libraries and asset bundles. Note that no library or
* asset will be loaded if it was already done before.
*
* @param {Object} libs
* @param {Array<string|string[]>} [libs.assetLibs=[]]
* The list of assets to load. Each list item may be a string (the xmlID
* of the asset to load) or a list of strings. The first level is loaded
* sequentially (so use this if the order matters) while the assets in
* inner lists are loaded in parallel (use this for efficiency but only
* if the order does not matter, should rarely be the case for assets).
* @param {string[]} [libs.cssLibs=[]]
* The list of CSS files to load. They will all be loaded in parallel but
* put in the DOM in the given order (only the order in the DOM is used
* to determine priority of CSS rules, not loaded time).
* @param {Array<string|string[]>} [libs.jsLibs=[]]
* The list of JS files to load. Each list item may be a string (the URL
* of the file to load) or a list of strings. The first level is loaded
* sequentially (so use this if the order matters) while the files in inner
* lists are loaded in parallel (use this for efficiency but only
* if the order does not matter).
* @param {string[]} [libs.cssContents=[]]
* List of inline styles to add after loading the CSS files.
* @param {string[]} [libs.jsContents=[]]
* List of inline scripts to add after loading the JS files.
* @param {Object} [context]
* additionnal rpc context to be merged with the default one
* @param {string} [tplRoute]
* Custom route to use for template rendering of the potential assets
* to load (see libs.assetLibs).
*
* @returns {Promise}
*/
function loadLibs(libs, context, tplRoute) {
var mutex = new concurrency.Mutex();
mutex.exec(function () {
var defs = [];
var cssLibs = [libs.cssLibs || []]; // Force loading in parallel
defs.push(_loadArray(cssLibs, ajax.loadCSS).then(function () {
if (libs.cssContents && libs.cssContents.length) {
$('head').append($('<style/>', {
html: libs.cssContents.join('\n'),
}));
}
}));
defs.push(_loadArray(libs.jsLibs || [], ajax.loadJS).then(function () {
if (libs.jsContents && libs.jsContents.length) {
$('head').append($('<script/>', {
html: libs.jsContents.join('\n'),
}));
}
}));
return Promise.all(defs);
});
mutex.exec(function () {
return _loadArray(libs.assetLibs || [], function (xmlID) {
return ajax.loadAsset(xmlID, context, tplRoute).then(function (asset) {
return ajax.loadLibs(asset);
});
});
});
function _loadArray(array, loadCallback) {
var _mutex = new concurrency.Mutex();
array.forEach(function (urlData) {
_mutex.exec(function () {
if (typeof urlData === 'string') {
return loadCallback(urlData);
}
return Promise.all(urlData.map(loadCallback));
});
});
return _mutex.getUnlockedDef();
}
return mutex.getUnlockedDef();
}
_.extend(ajax, {
jsonRpc: jsonRpc,
rpc: rpc,
loadCSS: loadCSS,
loadJS: loadJS,
loadXML: loadXML,
loadAsset: loadAsset,
loadLibs: loadLibs,
get_file: get_file,
post: post,
});
@@ -125,16 +125,6 @@ var ServicesMixin = {
});
return result;
},
/**
* @private
* @param {Object} libs - @see ajax.loadLibs
* @param {Object} [context] - @see ajax.loadLibs
* @param {Object} [tplRoute=this._loadLibsTplRoute] - @see ajax.loadLibs
* @returns {Promise}
*/
_loadLibs: function (libs, context, tplRoute) {
return this.call('ajax', 'loadLibs', libs, context, tplRoute || this._loadLibsTplRoute);
},
/**
* Builds and executes RPC query. Returns a promise resolved with
* the RPC result.
@@ -2,7 +2,6 @@ odoo.define('web.Session', function (require) {
"use strict";
var ajax = require('web.ajax');
var concurrency = require('web.concurrency');
var core = require('web.core');
var mixins = require('web.mixins');
var utils = require('web.utils');
@@ -39,7 +38,6 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
// remove it totally (but need to make sure the cookies are properly set)
this.name = "instance0";
// TODO: session store in cookie should be optional
this.qweb_mutex = new concurrency.Mutex();
this.currencies = {};
this._groups_def = {};
core.bus.on('invalidate_session', this, this._onInvalidateSession);
@@ -211,29 +209,8 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
}
});
},
load_qweb: function () {
return this.qweb_mutex.exec(async () => {
let templates;
if (odoo.loadTemplatesPromise) {
templates = await odoo.loadTemplatesPromise;
} else {
var cacheId = this.cache_hashes && this.cache_hashes.qweb;
const route = `/web/webclient/qweb/${(cacheId ? cacheId : Date.now())}?bundle=web.assets_qweb`;
templates = await (await fetch(route)).text();
}
const doc = new DOMParser().parseFromString(templates, "text/xml");
if (!doc) {
return;
}
const owlTemplates = [];
for (let child of doc.querySelectorAll("templates > [owl]")) {
child.removeAttribute('owl');
owlTemplates.push(child.outerHTML);
child.remove();
}
qweb.add_template(doc);
this.owlTemplates = `<templates> ${owlTemplates.join('\n')} </templates>`;
});
load_qweb: async function () {
await odoo.ready(/\.bundle\.xml$/);
},
get_currency: function (currency_id) {
return this.currencies[currency_id];
+3 -15
View File
@@ -1,10 +1,11 @@
odoo.define('web.Widget', function (require) {
"use strict";
var ajax = require('web.ajax');
var core = require('web.core');
var mixins = require('web.mixins');
var ServicesMixin = require('web.ServicesMixin');
const { loadBundle } = require("@web/core/assets");
/**
* Base class for all visual components. Provides a lot of functions helpful
@@ -76,14 +77,6 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, ServicesMixin, {
* @type {null|string}
*/
template: null,
/**
* List of paths to xml files that need to be loaded before the widget can
* be rendered. This will not induce loading anything that has already been
* loaded.
*
* @type {null|string[]}
*/
xmlDependencies: null,
/**
* List of paths to css files that need to be loaded before the widget can
* be rendered. This will not induce loading anything that has already been
@@ -142,13 +135,8 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, ServicesMixin, {
*/
willStart: function () {
var proms = [];
if (this.xmlDependencies) {
proms.push.apply(proms, _.map(this.xmlDependencies, function (xmlPath) {
return ajax.loadXML(xmlPath, core.qweb);
}));
}
if (this.jsLibs || this.cssLibs || this.assetLibs) {
proms.push(this._loadLibs(this));
proms.push(loadBundle(this));
}
return Promise.all(proms);
},
@@ -2,6 +2,7 @@ odoo.define('web.FieldWrapper', function (require) {
"use strict";
const { ComponentWrapper } = require('web.OwlCompatibility');
const { templates } = require("@web/core/assets");
const field_utils = require('web.field_utils');
const { App, reactive } = owl;
@@ -127,7 +128,7 @@ odoo.define('web.FieldWrapper', function (require) {
// (only the wrapper is instantiated), so we instantiate one
// with the same props, get its 'isSet' status, and destroy it.
const app = new App(null, {
templates: window.__OWL_TEMPLATES__,
templates,
env: this.env,
dev: this.env.isDebug(),
translateFn: owl.Component.env._t,
@@ -147,7 +148,7 @@ odoo.define('web.FieldWrapper', function (require) {
// (only the wrapper is instantiated), so we instantiate one
// with the same props, get its 'isValid' status, and destroy it.
const app = new App(null, {
templates: window.__OWL_TEMPLATES__,
templates,
env: this.env,
dev: this.env.isDebug(),
translateFn: owl.Component.env._t,
@@ -2,6 +2,7 @@ odoo.define('web.OwlCompatibility', function (require) {
"use strict";
const { LegacyComponent } = require("@web/legacy/legacy_component");
const { templates } = require("@web/core/assets");
/**
* This file defines the necessary tools for the transition phase where Odoo
@@ -293,7 +294,7 @@ odoo.define('web.OwlCompatibility', function (require) {
function standaloneAdapter(props = {}, ref = bodyRef) {
const env = owl.Component.env;
const app = new App(null, {
templates: window.__OWL_TEMPLATES__,
templates,
env,
dev: env.isDebug(),
translatableAttributes: ["data-tooltip"],
@@ -548,7 +549,7 @@ odoo.define('web.OwlCompatibility', function (require) {
const env = owl.Component.env;
const appConfig = {
env,
templates: window.__OWL_TEMPLATES__,
templates,
dev: env.isDebug(),
translatableAttributes: ["data-tooltip"],
translateFn: env._t,
@@ -21,8 +21,8 @@ import {
} from "../../utils";
import { standaloneAdapter } from "web.OwlCompatibility";
import { fetchAndProcessTemplates, loadBundle } from "@web/core/assets";
import { makeEnv, startServices } from "@web/env";
import { assets, templates } from '@web/core/assets';
import { MainComponentsContainer } from "@web/core/main_components_container";
import { browser } from '@web/core/browser/browser';
import { jsonrpc } from '@web/core/network/rpc_service';
@@ -263,8 +263,6 @@ export const PublicRoot = publicWidget.RootWidget.extend({
}
params.kwargs.context = _computeContext.call(this, params.kwargs.context, noContextKeys);
}
} else if (payload.service === 'ajax' && payload.method === 'loadLibs') {
args[1] = _computeContext.call(this, args[1]);
} else {
return;
}
@@ -408,20 +406,18 @@ export async function createPublicRoot(RootWidget) {
const wowlEnv = makeEnv();
const templates = await fetchAndProcessTemplates("web.assets_frontend");
window.__OWL_TEMPLATES__ = templates;
await startServices(wowlEnv);
mapLegacyEnvToWowlEnv(legacyEnv, wowlEnv);
// The root widget's parent is a standalone adapter so that it has _trigger_up
const publicRoot = new RootWidget(standaloneAdapter({ Component }));
const app = new App(MainComponentsContainer, {
templates,
env: wowlEnv,
dev: wowlEnv.debug,
templates: window.__OWL_TEMPLATES__,
translateFn: _t,
translatableAttributes: ["data-tooltip"],
});
loadBundle.app = app;
assets.defaultApp = app;
await Promise.all([
app.mount(document.body),
publicRoot.attachTo(document.body),
@@ -2,19 +2,10 @@ odoo.define('web.AjaxService', function (require) {
"use strict";
var AbstractService = require('web.AbstractService');
var ajax = require('web.ajax');
var core = require('web.core');
var session = require('web.session');
var AjaxService = AbstractService.extend({
/**
* @param {Object} libs - @see ajax.loadLibs
* @param {Object} [context] - @see ajax.loadLibs
* @param {Object} [tplRoute] - @see ajax.loadLibs
*/
loadLibs: function (libs, context, tplRoute) {
return ajax.loadLibs(libs, context, tplRoute);
},
rpc: function (route, args, options, target) {
var rpcPromise;
var promise = new Promise(function (resolve, reject) {
+2 -1
View File
@@ -15,6 +15,7 @@ import * as AbstractService from "web.AbstractService";
import legacyEnv from "web.env";
import * as session from "web.session";
import * as makeLegacyWebClientService from "web.pseudo_web_client";
import { templates } from "@web/core/assets";
const { Component, whenReady } = owl;
@@ -51,6 +52,6 @@ export const legacySetupProm = new Promise((resolve) => {
serviceRegistry.add(legacyServiceName, wowlToLegacyServiceMapper(legacyEnv));
}
await Promise.all([whenReady(), session.is_bound]);
legacyEnv.templates = session.owlTemplates;
legacyEnv.templates = templates;
legacySetupResolver(legacyEnv);
})();
+4 -11
View File
@@ -12,8 +12,8 @@ import {
getLocalState,
searchModelStateToLegacy,
} from "./backend_utils";
import { getBundle, loadBundle } from "@web/core/assets";
import { registry } from "@web/core/registry";
import { loadPublicAsset } from "@web/core/assets";
import { LegacyComponent } from "./legacy_component";
const { xml, onWillStart } = owl;
@@ -182,14 +182,7 @@ for (const [name, action] of Object.entries(legacyViewRegistry.entries())) {
}
legacyViewRegistry.onAdd(registerView);
export async function loadLegacyViews({ orm, rpc }) {
if (!orm && rpc) {
orm = {
call: (...callArgs) => {
const [model, method, args = [], kwargs = {}] = callArgs;
return rpc({ model, method, args, kwargs });
},
};
}
await loadPublicAsset("web.assets_backend_legacy_lazy", orm);
export async function loadLegacyViews() {
const assets = await getBundle("web.assets_backend_legacy_lazy");
await loadBundle(assets);
}
+4 -8
View File
@@ -3,10 +3,10 @@
import { makeEnv, startServices } from "./env";
import { legacySetupProm } from "./legacy/legacy_setup";
import { mapLegacyEnvToWowlEnv } from "./legacy/utils";
import { processTemplates, loadBundle } from "./core/assets";
import { localization } from "@web/core/l10n/localization";
import { session } from "@web/session";
import { renderToString } from "./core/utils/render";
import { assets, templates } from "@web/core/assets";
const { App, whenReady } = owl;
@@ -29,25 +29,21 @@ export async function startWebClient(Webclient) {
// setup environment
const env = makeEnv();
const [, templates] = await Promise.all([
startServices(env),
odoo.loadTemplatesPromise.then(processTemplates),
]);
await startServices(env);
// start web client
await whenReady();
window.__OWL_TEMPLATES__ = templates;
const legacyEnv = await legacySetupProm;
mapLegacyEnvToWowlEnv(legacyEnv, env);
const app = new App(Webclient, {
env,
templates,
dev: env.debug,
templates: window.__OWL_TEMPLATES__,
translatableAttributes: ["data-tooltip"],
translateFn: env._t,
});
renderToString.app = app;
loadBundle.app = app;
assets.defaultApp = app;
const root = await app.mount(document.body);
const classList = document.body.classList;
if (localization.direction === "rtl") {
@@ -21,6 +21,7 @@ import {
triggerHotkey,
} from "../helpers/utils";
import { makeParent } from "./tooltip/tooltip_service_tests";
import { templates } from "@web/core/assets";
const { App, Component, xml } = owl;
const serviceRegistry = registry.category("services");
@@ -392,7 +393,7 @@ QUnit.module("Components", ({ beforeEach }) => {
env = await makeTestEnv();
const app = new App(Parent, {
env,
templates: window.__OWL_TEMPLATES__,
templates,
test: true,
});
registerCleanup(() => app.destroy());
@@ -9,6 +9,7 @@ import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { getFixture, nextTick, patchWithCleanup, triggerEvent } from "../../helpers/utils";
import { registerCleanup } from "../../helpers/cleanup";
import { makeFakeLocalizationService } from "../../helpers/mock_services";
import { templates } from "@web/core/assets";
const { App, Component, useState, xml } = owl;
@@ -82,7 +83,7 @@ export async function makeParent(Child, options = {}) {
const app = new App(Parent, {
env,
target,
templates: window.__OWL_TEMPLATES__,
templates,
test: true,
});
registerCleanup(() => app.destroy());
@@ -1,8 +1,6 @@
/** @odoo-module **/
import { loadJS, loadCSS, fetchAndProcessTemplates } from "@web/core/assets";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
import { loadJS, loadCSS } from "@web/core/assets";
QUnit.module("utils", () => {
QUnit.module("Assets");
@@ -30,22 +28,4 @@ QUnit.module("utils", () => {
"Document contains a link with the href we asked to load"
);
});
QUnit.test("fetchAndProcessTemplates: load invalid bundle", function (assert) {
let lastFetchedURL;
patchWithCleanup(browser, {
fetch: function (url) {
lastFetchedURL = url;
return Promise.reject(`Failed to load ressource at "${url}"`);
},
});
assert.rejects(
fetchAndProcessTemplates("web.some_invalid_bundle"),
"Trying to load an invalid bundle rejects the promise"
);
assert.ok(
/\/web\/webclient\/qweb\/.*\?bundle=web\.some_invalid_bundle$/.test(lastFetchedURL),
"Loading a bundle calls the /web/webclient/qweb route with the corresponding bundle query parameter"
);
});
});
+26 -3
View File
@@ -15,6 +15,7 @@ import {
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { assets } from "@web/core/assets";
const serviceRegistry = registry.category("services");
@@ -2200,12 +2201,11 @@ export async function makeMockServer(serverData, mockRPC) {
// simulates that we serialized the call to be passed in a real request
args = JSON.parse(JSON.stringify(args));
}
const performRPC = (route, args) => mockServer.performRPC(route, args);
if (mockRPC) {
res = await mockRPC(route, args, performRPC);
res = await mockRPC(route, args, mockServer.performRPC.bind(mockServer));
}
if (res === undefined) {
res = await performRPC(route, args);
res = await mockServer.performRPC(route, args);
}
return res;
};
@@ -2213,6 +2213,29 @@ export async function makeMockServer(serverData, mockRPC) {
patchWithCleanup(browser, {
fetch: makeMockFetch(_mockRPC),
});
if (mockRPC) {
const { loadJS, loadCSS } = assets;
patchWithCleanup(assets, {
loadJS: async function (ressource) {
let res = await mockRPC(ressource, {});
if (res === undefined) {
res = await loadJS(ressource);
} else {
console.log("%c[assets] fetch (mock) JS ressource " + ressource, "color: #66e; font-weight: bold;");
}
return res;
},
loadCSS: async function (ressource) {
let res = await mockRPC(ressource, {});
if (res === undefined) {
res = await loadCSS(ressource);
} else {
console.log("%c[assets] fetch (mock) CSS ressource " + ressource, "color: #66e; font-weight: bold;");
}
return res;
},
});
}
// Replace RPC service
serviceRegistry.add("rpc", rpcService, { force: true });
return mockServer;
+2 -1
View File
@@ -7,6 +7,7 @@ import { Deferred } from "@web/core/utils/concurrency";
import { patch, unpatch } from "@web/core/utils/patch";
import { isVisible } from "@web/core/utils/ui";
import { registerCleanup } from "./cleanup";
import { templates } from "@web/core/assets";
const { App, onMounted, onPatched, useComponent } = owl;
@@ -587,7 +588,7 @@ export async function mount(Comp, target, config = {}) {
env = env || {};
const configuration = {
env,
templates: window.__OWL_TEMPLATES__,
templates,
test: true,
props,
};
@@ -1,35 +0,0 @@
odoo.define('web.ajax_tests', function (require) {
"use strict";
var ajax = require('web.ajax');
QUnit.module('core', function () {
var test_css_url = '/test_assetsbundle/static/src/css/test_cssfile1.css';
var test_link_selector = 'link[href="' + test_css_url + '"]';
QUnit.module('ajax', {
beforeEach: function () {
$(test_link_selector).remove();
},
afterEach: function () {
$(test_link_selector).remove();
}
});
QUnit.test('loadCSS', function (assert) {
var done = assert.async();
assert.expect(2);
ajax.loadCSS(test_css_url).then(function () {
var $links = $(test_link_selector);
assert.strictEqual($links.length, 1, "The css should be added to the dom.");
ajax.loadCSS(test_css_url).then(function () {
var $links = $(test_link_selector);
assert.strictEqual($links.length, 1, "The css should have been added only once.");
done();
});
});
});
});
});
@@ -469,6 +469,7 @@ odoo.define('web.owl_dialog_tests', function (require) {
`,
"event,2,form": `<form><sheet><field name="display_name" /></sheet></form>`,
},
activateMockServer: true,
};
const target = getFixture();
@@ -1955,6 +1955,14 @@ var MockServer = Class.extend({
* @returns {any}
*/
_performFetch(resource, init) {
if (resource.match(/\/static(\/\S+\/|\/)libs?/)) {
// every lib must be includes into the test bundle.
return true;
}
if (resource.match(/\/web\/bundle\/[^.]+\.[^.]+/)) {
// every asset must be includes into the test bundle.
return true;
}
throw new Error("Unimplemented resource: " + resource);
},
/**
@@ -4,7 +4,7 @@ odoo.define('web.test_env', async function (require) {
const Bus = require('web.Bus');
const session = require('web.session');
const { makeTestEnvServices } = require('@web/../tests/legacy/helpers/test_services');
const { templates } = require("@web/core/assets");
const { renderToString } = require('@web/core/utils/render');
const { App, Component } = owl;
@@ -21,7 +21,7 @@ odoo.define('web.test_env', async function (require) {
*/
function makeTestEnvironment(env = {}, providedRPC = null) {
if (!app) {
app = new App(null, { templates: window.__OWL_TEMPLATES__, test: true });
app = new App(null, { templates, test: true });
renderToString.app = app;
}
@@ -23,7 +23,6 @@ function makeTestEnvServices(env) {
rpc() {
return env.session.rpc(...arguments); // Compatibility Legacy Widgets
},
loadLibs() {}
},
rpc(params, options) {
const query = buildQuery(params);
@@ -22,7 +22,9 @@ const makeTestEnvironment = require('web.test_env');
const MockServer = require('web.MockServer');
const RamStorage = require('web.RamStorage');
const session = require('web.session');
const { patchDate } = require("@web/../tests/helpers/utils");
const { patchWithCleanup, patchDate } = require("@web/../tests/helpers/utils");
const { browser } = require("@web/core/browser/browser");
const { assets } = require("@web/core/assets");
const { processArch } = require("@web/legacy/legacy_load_views");
const { Component } = owl;
@@ -333,7 +335,7 @@ async function addMockEnvironmentOwl(Component, params, mockServer) {
if (!mockServer) {
let Server = MockServer;
if (params.mockFetch) {
Server = MockServer.extend({ _performFetch: params.mockFetch });
Server = Server.extend({ _performFetch: params.mockFetch });
}
if (params.mockRPC) {
Server = Server.extend({ _performRpc: params.mockRPC });
@@ -346,6 +348,40 @@ async function addMockEnvironmentOwl(Component, params, mockServer) {
});
}
patchWithCleanup(browser, {
fetch: async (url, args) => {
const result = await mockServer.performFetch(url, args || {});
return {
json: () => result,
text: () => result,
};
},
});
if (params.mockFetch) {
const { loadJS, loadCSS } = assets;
patchWithCleanup(assets, {
loadJS: async function (ressource) {
let res = await params.mockFetch(ressource, {});
if (res === undefined) {
res = await loadJS(ressource);
} else {
console.log("%c[assets] fetch (mock) JS ressource " + ressource, "color: #66e; font-weight: bold;");
}
return res;
},
loadCSS: async function (ressource) {
let res = await params.mockFetch(ressource, {});
if (res === undefined) {
res = await loadCSS(ressource);
} else {
console.log("%c[assets] fetch (mock) CSS ressource " + ressource, "color: #66e; font-weight: bold;");
}
return res;
},
});
}
// remove the multi-click delay for the quick edit in form view
const initialQuickEditDelay = FormController.prototype.multiClickTime;
FormController.prototype.multiClickTime = params.formMultiClickTime || 0;
@@ -41,13 +41,6 @@ QUnit.module('LegacyViews', {
var prom = testUtils.makeTestPromise();
var loadJS = ajax.loadJS;
const libraryLoadingStartedPromise = testUtils.makeTestPromise();
ajax.loadJS = function (url) {
libraryLoadingStartedPromise.resolve();
assert.step(url);
return prom.then(function () {
assert.step(url + ' loaded');
});
};
var View = AbstractView.extend({
jsLibs: [['a', 'b']],
@@ -57,6 +50,14 @@ QUnit.module('LegacyViews', {
arch: '<fake/>',
data: this.data,
model: 'fake_model',
mockFetch: function (url) {
libraryLoadingStartedPromise.resolve();
assert.step(url);
return prom.then(function () {
assert.step(url + ' loaded');
return true;
});
},
}).then(function (view) {
assert.verifySteps(['a loaded', 'b loaded'],
"should wait for both libs to be loaded");
@@ -81,13 +82,6 @@ QUnit.module('LegacyViews', {
};
var loadJS = ajax.loadJS;
const libraryLoadingStartedPromise = testUtils.makeTestPromise();
ajax.loadJS = function (url) {
libraryLoadingStartedPromise.resolve();
assert.step(url);
return proms[url].then(function () {
assert.step(url + ' loaded');
});
};
var View = AbstractView.extend({
jsLibs: [
@@ -100,6 +94,14 @@ QUnit.module('LegacyViews', {
arch: '<fake/>',
data: this.data,
model: 'fake_model',
mockFetch: function (url) {
libraryLoadingStartedPromise.resolve();
assert.step(url);
return proms[url].then(function () {
assert.step(url + ' loaded');
return true;
});
},
}).then(function (view) {
assert.verifySteps(['c loaded'], "should wait for all libs to be loaded");
ajax.loadJS = loadJS;
@@ -10277,12 +10277,11 @@ QUnit.module('LegacyViews', {
arch: `<form>
<widget name="pie_chart" title="qux by product" attrs="{'measure': 'qux', 'groupby': 'product_id'}"/>
</form>`,
mockRPC(route, args) {
if (args.method === "render_public_asset") {
assert.deepEqual(args.args, ["web.assets_backend_legacy_lazy"]);
mockFetch(route) {
if (route.includes("/web/bundle/")) {
assert.deepEqual(route, location.origin + "/web/bundle/web.assets_backend_legacy_lazy");
return Promise.resolve(true);
}
return this._super(...arguments);
}
});
+44 -14
View File
@@ -2,8 +2,10 @@
import core, { _t } from "web.core";
import session from "web.session";
import { assets, templates } from "@web/core/assets";
import { browser, makeRAMLocalStorage } from "@web/core/browser/browser";
import { patchTimeZone, patchWithCleanup } from "@web/../tests/helpers/utils";
import { nextTick, patchTimeZone, patchWithCleanup } from "@web/../tests/helpers/utils";
import { memoize } from "@web/core/utils/functions";
import { legacyProm } from "web.test_legacy";
import { registerCleanup } from "./helpers/cleanup";
import { utils } from "./helpers/mock_env";
@@ -15,8 +17,7 @@ import { loadLanguages } from "@web/core/l10n/translation";
transitionConfig.disabled = true;
import { patch } from "@web/core/utils/patch";
import { processTemplates } from "@web/core/assets";
const { App, whenReady, loadFile } = owl;
const { App, whenReady } = owl;
const { prepareRegistriesWithCleanup } = utils;
@@ -263,7 +264,7 @@ function patchSessionInfo() {
* @param {Document} templates Document containing the templates to
* process.
*/
function removeUnwantedAttrsFromTemplates(templates, attrs) {
function removeUnwantedAttrsFromTemplates(attrs) {
function replaceAttr(attrName, prefix, element) {
const attrKey = `${prefix}${attrName}`;
const attrValue = element.getAttribute(attrKey);
@@ -280,6 +281,45 @@ function removeUnwantedAttrsFromTemplates(templates, attrs) {
}
}
// alt attribute causes issues with scroll tests. Indeed, alt is
// displayed between the time we scroll programatically and the time
// we assert for the scroll position. The src attribute is removed
// as well to make sure images won't trigger a GET request on the
// server.
// Clean up templates that have already been added.
removeUnwantedAttrsFromTemplates(['alt', 'src']);
const { loadXML, getBundle, loadJS, loadCSS } = assets;
patch(assets, 'TestAssetsLoadXML', {
loadXML: function (templates) {
console.log("%c[assets] fetch XML ressource", "color: #66e; font-weight: bold;");
// Clean up new templates that might be added later.
loadXML(templates);
removeUnwantedAttrsFromTemplates(['alt', 'src']);
},
getBundle: memoize(async function (xmlID) {
console.log("%c[assets] fetch libs from xmlID: " + xmlID, "color: #66e; font-weight: bold;");
return getBundle(xmlID);
}),
loadJS: memoize(async function (ressource) {
if (ressource.match(/\/static(\/\S+\/|\/)libs?/)) {
console.log("%c[assets] fetch (mock) JS ressource: " + ressource, "color: #66e; font-weight: bold;");
return nextTick();
}
console.log("%c[assets] fetch JS ressource: " + ressource, "color: #66e; font-weight: bold;");
return loadJS(ressource);
}),
loadCSS: memoize(async function (ressource) {
if (ressource.match(/\/static(\/\S+\/|\/)libs?/)) {
console.log("%c[assets] fetch (mock) CSS ressource: " + ressource, "color: #66e; font-weight: bold;");
return nextTick();
}
console.log("%c[assets] fetch CSS ressource: " + ressource, "color: #66e; font-weight: bold;");
return loadCSS(ressource);
}),
});
export async function setupTests() {
QUnit.testStart(() => {
checkGlobalObjectsIntegrity();
@@ -294,16 +334,6 @@ export async function setupTests() {
patchSessionInfo();
});
const templatesUrl = `/web/webclient/qweb/${new Date().getTime()}?bundle=web.assets_qweb`;
const templates = await loadFile(templatesUrl);
window.__OWL_TEMPLATES__ = processTemplates(templates);
// alt attribute causes issues with scroll tests. Indeed, alt is
// displayed between the time we scroll programatically and the time
// we assert for the scroll position. The src attribute is removed
// as well to make sure images won't trigger a GET request on the
// server.
removeUnwantedAttrsFromTemplates(window.__OWL_TEMPLATES__, ["alt", "src"]);
session.owlTemplates = window.__OWL_TEMPLATES__;
await Promise.all([whenReady(), legacyProm]);
// make sure images do not trigger a GET on the server
+1 -1
View File
@@ -6,7 +6,6 @@ from . import test_image
from . import test_ir_model
from . import test_js
from . import test_menu
from . import test_serving_base
from . import test_click_everywhere
from . import test_base_document_layout
from . import test_load_menus
@@ -14,4 +13,5 @@ from . import test_profiler
from . import test_session_info
from . import test_read_progress_bar
from . import test_assets
from . import test_assets_xml
from . import test_login
+2 -3
View File
@@ -39,20 +39,19 @@ class TestAssetsGenerateTimeCommon(odoo.tests.TransactionCase):
_logger.info('Error detected while generating bundle %r %s', bundle, assets_type)
@odoo.tests.tagged('post_install', '-at_install')
@odoo.tests.tagged('post_install', '-at_install', 'assets_bundle')
class TestLogsAssetsGenerateTime(TestAssetsGenerateTimeCommon):
def test_logs_assets_generate_time(self):
"""
The purpose of this test is to monitor the time of assets bundle generation.
This is not meant to test the generation failure, hence the try/except and the mute logger.
For example, 'web.assets_qweb' is contains only static xml.
"""
for bundle, duration in self.generate_bundles():
_logger.info('Bundle %r generated in %.2fs', bundle, duration)
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'bundle_generation')
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'assets_bundle')
class TestAssetsGenerateTime(TestAssetsGenerateTimeCommon):
"""
This test is meant to be run nightly to ensure bundle generation does not exceed
@@ -8,57 +8,23 @@ from datetime import datetime
from lxml import etree
import logging
import odoo
from odoo.tests.common import BaseCase, HttpCase, tagged
from odoo.tools import topological_sort
from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers
from odoo.addons.base.models.assetsbundle import AssetsBundle, WebAsset
_logger = logging.getLogger(__name__)
def sample(population):
return random.sample(
population,
random.randint(0, min(len(population), 5)))
class TestModulesLoading(BaseCase):
def setUp(self):
self.mods = [str(i) for i in range(1000)]
def test_topological_sort(self):
random.shuffle(self.mods)
modules = [
(k, sample(self.mods[:i]))
for i, k in enumerate(self.mods)]
random.shuffle(modules)
ms = dict(modules)
seen = set()
sorted_modules = topological_sort(ms)
for module in sorted_modules:
deps = ms[module]
self.assertGreaterEqual(
seen, set(deps),
'Module %s (index %d), ' \
'missing dependencies %s from loaded modules %s' % (
module, sorted_modules.index(module), deps, seen
))
seen.add(module)
class TestStaticInheritanceCommon(BaseCase):
class TestStaticInheritanceCommon(odoo.tests.TransactionCase):
def setUp(self):
super().setUp()
# output is "manifest_glob" return
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
('module_2_file_1', 'module_2', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<span>Ho !</span>
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
@@ -66,14 +32,16 @@ class TestStaticInheritanceCommon(BaseCase):
<div>And I grew strong</div>
</t>
</templates>
""",
'module_2_file_1': b"""
""",
'/module_2/static/xml/file_1.xml': """
<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="//span" position="attributes">
<attribute name="type">Scary screams</attribute>
</xpath>
<xpath expr="//div[2]" position="after">
<div>But then I spent so many nights thinking how you did me wrong</div>
</xpath>
@@ -87,58 +55,82 @@ class TestStaticInheritanceCommon(BaseCase):
</xpath>
</form>
</templates>
""",
""",
}
self._set_patchers()
self._toggle_patchers('start')
self._reg_replace_ws = r"\s|\t"
self._patch = patch.object(WebAsset, '_fetch_content', lambda asset: self.template_files[asset.url])
self._patch.start()
def tearDown(self):
super().tearDown()
self._toggle_patchers('stop')
self._patch.stop()
def renderBundle(self, debug=False):
files = []
for url in self.template_files:
atype = 'text/xml'
if '.js' in url:
atype = 'text/javascript'
files.append({
'atype': atype,
'url': url,
'filename': url,
'content': None,
'media': None,
})
asset = AssetsBundle('web.test_bundle', files, env=self.env, css=False, js=True)
# to_node return the files descriptions and generate attachments.
asset.to_node(css=False, js=False, debug=debug and 'assets' or '')
content = asset.xml(show_inherit_info=debug)
return f'<templates xml:space="preserve">\n{content}\n</templates>'
# 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)
self.assertEqual(etree.fromstring(output), etree.fromstring(expected))
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([asset_path[1] for asset_path in self.asset_paths])
def _set_patchers(self):
def _patched_for_get_asset_paths(*args, **kwargs):
# Ordered by module
return self.asset_paths
def _patch_for_read_addon_file(*args, **kwargs):
return self.template_files[args[1]]
self.patchers = [
patch.object(HomeStaticTemplateHelpers, '_get_asset_paths', _patched_for_get_asset_paths),
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)()
@tagged('static_templates')
@tagged('assets_bundle', 'static_templates')
class TestStaticInheritance(TestStaticInheritanceCommon):
# Actual test cases
def test_static_inheritance_01(self):
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
def test_static_with_debug_mode(self):
expected = """
<templates xml:space="preserve">
<!-- Filepath: /module_1/static/xml/file_1.xml -->
<form t-name="template_1_1" random-attr="gloria">
<span>Ho !</span>
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
<!-- Filepath: /module_1/static/xml/file_1.xml => /module_2/static/xml/file_1.xml -->
<t t-name="template_1_2">
<div>And I grew strong</div>
<!-- Filepath: /module_2/static/xml/file_1.xml ; position="after" ; {'expr': '//div[1]'} --><div>And I learned how to get along</div>
</t>
<!-- Filepath: /module_1/static/xml/file_1.xml => /module_2/static/xml/file_1.xml -->
<form t-name="template_2_1" random-attr="gloria"><!-- Filepath: /module_2/static/xml/file_1.xml ; position="attributes" ; {'expr': '//span'} -->
<span type="Scary screams">Ho !</span>
<div>At first I was afraid</div>
<!-- Filepath: /module_2/static/xml/file_1.xml ; position="after" ; {'expr': '//div[1]'} --><div>I was petrified</div>
<!-- Filepath: /module_2/static/xml/file_1.xml ; position="after" ; {'expr': '//div[2]'} --><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>
<!-- Filepath: /module_2/static/xml/file_1.xml -->
<div t-name="template_2_2">
<div>And I learned how to get along</div>
</div>
</templates>
"""
self.assertXMLEqual(self.renderBundle(debug=True), expected)
def test_static_inheritance_01(self):
expected = """
<templates xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<span>Ho !</span>
<div>At first I was afraid</div>
<div>Kept thinking I could never live without you by my side</div>
</form>
@@ -147,6 +139,7 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
<div>And I learned how to get along</div>
</t>
<form t-name="template_2_1" random-attr="gloria">
<span type="Scary screams">Ho !</span>
<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>
@@ -157,12 +150,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_static_inheritance_02(self):
self.template_files = {
'module_1_file_1': b'''
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -174,14 +166,10 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</xpath>
</form>
</templates>
'''
""",
}
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates 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>
@@ -193,13 +181,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_static_inheritance_03(self):
self.maxDiff = None
self.template_files = {
'module_1_file_1': b'''
'/module_1/static/xml/file_1.xml': '''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>At first I was afraid</div>
@@ -216,12 +202,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
'''
}
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates 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>
@@ -237,17 +219,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_static_inheritance_in_same_module(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
('module_1_file_2', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b'''
'/module_1/static/xml/file_1.xml': '''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>At first I was afraid</div>
@@ -256,7 +232,7 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
''',
'module_1_file_2': b'''
'/module_1/static/xml/file_2.xml': '''
<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">
@@ -266,9 +242,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
'''
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates 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>
@@ -280,16 +255,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_static_inheritance_in_same_file(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b'''
'/module_1/static/xml/file_1.xml': '''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>At first I was afraid</div>
@@ -303,9 +273,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
''',
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates 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>
@@ -317,15 +286,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_static_inherit_extended_template(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b'''
'/module_1/static/xml/file_1.xml': '''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>At first I was afraid</div>
@@ -344,9 +309,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
''',
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="template_1_1">
<div>At first I was afraid</div>
<div>I was petrified</div>
@@ -360,17 +324,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_sibling_extension(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
('module_2_file_1', 'module_2', 'bundle_1'),
('module_3_file_1', 'module_3', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b'''
'/module_1/static/xml/file_1.xml': '''
<templates id="template" xml:space="preserve">
<form t-name="template_1_1">
<div>I am a man of constant sorrow</div>
@@ -379,7 +337,7 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
''',
'module_2_file_1': b'''
'/module_2/static/xml/file_1.xml': '''
<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">
@@ -389,7 +347,7 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
''',
'module_3_file_1': b'''
'/module_3/static/xml/file_1.xml': '''
<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">
@@ -399,10 +357,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
'''
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="template_1_1">
<div>I am a man of constant sorrow</div>
<div>In constant sorrow all through his days</div>
@@ -411,21 +367,24 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</form>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_static_misordered_modules(self):
self.asset_paths.reverse()
files = self.template_files
self.template_files = {
'/module_2/static/xml/file_1.xml': files['/module_2/static/xml/file_1.xml'],
'/module_1/static/xml/file_1.xml': files['/module_1/static/xml/file_1.xml'],
}
with self.assertRaises(ValueError) as ve:
HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
self.renderBundle(debug=False)
self.assertEqual(
str(ve.exception),
'Module module_1 not loaded or inexistent, or templates of addon being loaded (module_2) are misordered'
"Module 'module_1' not loaded or inexistent (try to inherit 'template_1_1'), or templates of addon being loaded 'module_2' are misordered (template 'template_2_1')"
)
def test_static_misordered_templates(self):
self.template_files['module_2_file_1'] = b"""
self.template_files['/module_2/static/xml/file_1.xml'] = """
<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">
@@ -438,22 +397,19 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
"""
with self.assertRaises(ValueError) as ve:
HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
self.renderBundle(debug=False)
self.assertEqual(
str(ve.exception),
'No template found to inherit from. Module module_2 and template name template_2_2'
"Cannot create 'module_2.template_2_1' because the template to inherit 'module_2.template_2_2' is not found.",
)
def test_replace_in_debug_mode(self):
"""
Replacing a template's meta definition in place doesn't keep the original attrs of the template
"""
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -466,24 +422,18 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<div overriden-attr="overriden" t-name="template_1_1">
And I grew strong
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_replace_in_debug_mode2(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -500,10 +450,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<div t-name="template_1_1">
And I grew strong
<p>And I learned how to get along</p>
@@ -511,19 +459,15 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_replace_in_debug_mode3(self):
"""Text outside of a div which will replace a whole template
becomes outside of the template
This doesn't mean anything in terms of the business of template inheritance
But it is in the XPATH specs"""
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -540,10 +484,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<div t-name="template_1_1">
And I grew strong
<p>And I learned how to get along</p>
@@ -551,18 +493,14 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
And so you're back
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_replace_root_node_tag(self):
"""
Root node IS targeted by //NODE_TAG in xpath
"""
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -578,28 +516,21 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<div t-name="template_1_1">
Form replacer
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_replace_root_node_tag_in_primary(self):
"""
Root node IS targeted by //NODE_TAG in xpath
"""
self.maxDiff = None
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -613,10 +544,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
<form>Inner Form</form>
@@ -626,19 +555,15 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_inherit_primary_replace_debug(self):
"""
The inheriting template has got both its own defining attrs
and new ones if one is to replace its defining root node
"""
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -654,10 +579,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
</form>
@@ -667,16 +590,12 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_replace_in_nodebug_mode1(self):
"""Comments already in the arch are ignored"""
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -694,10 +613,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=False)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<div t-name="template_1_1">
And I grew strong
<p>And I learned how to get along</p>
@@ -705,15 +622,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_inherit_from_dotted_tname_1(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="module_1.template_1_1.dot" random-attr="gloria">
<div>At first I was afraid</div>
@@ -729,10 +642,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="module_1.template_1_1.dot" random-attr="gloria">
<div>At first I was afraid</div>
</form>
@@ -742,15 +653,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_inherit_from_dotted_tname_2(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1.dot" random-attr="gloria">
<div>At first I was afraid</div>
@@ -766,10 +673,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="template_1_1.dot" random-attr="gloria">
<div>At first I was afraid</div>
</form>
@@ -779,15 +684,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_inherit_from_dotted_tname_2bis(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="template_1_1.dot" random-attr="gloria">
<div>At first I was afraid</div>
@@ -803,10 +704,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="template_1_1.dot" random-attr="gloria">
<div>At first I was afraid</div>
</form>
@@ -816,15 +715,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_inherit_from_dotted_tname_2ter(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="module_1" random-attr="gloria">
<div>At first I was afraid</div>
@@ -840,10 +735,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="module_1" random-attr="gloria">
<div>At first I was afraid</div>
</form>
@@ -853,16 +746,11 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(contents, expected)
self.assertXMLEqual(self.renderBundle(debug=False), expected)
def test_inherit_from_dotted_tname_3(self):
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
('module_2_file_1', 'module_2', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
'/module_1/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<form t-name="module_1.template_1_1.dot" random-attr="gloria">
<div>At first I was afraid</div>
@@ -870,7 +758,7 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
""",
'module_2_file_1': b"""
'/module_2/static/xml/file_1.xml': """
<templates id="template" xml:space="preserve">
<t t-name="template_2_1" t-inherit="module_1.template_1_1.dot" t-inherit-mode="primary">
<xpath expr="." position="replace">
@@ -883,10 +771,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
"""
}
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
expected = b"""
<templates>
expected = """
<templates xml:space="preserve">
<form t-name="module_1.template_1_1.dot" random-attr="gloria">
<div>At first I was afraid</div>
</form>
@@ -896,40 +782,68 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</div>
</templates>
"""
self.assertXMLEqual(self.renderBundle(debug=False), expected)
self.assertXMLEqual(contents, expected)
@tagged('static_templates')
class TestHttpStaticInheritance(HttpCase):
def test_static_attachments(self):
url = '/test_module/test_file.xml'
self.env['ir.attachment'].create({
'name': 'test_attachment',
'url': url,
'res_model': 'ir.ui.view',
'type': 'binary',
'raw': b"""
<templates>
<t t-name="test_template">
<div class="test_div" />
def test_inherit_and_qweb_extend(self):
self.template_files['/module_1/static/xml/file_2.xml'] = """
<templates id="template" xml:space="preserve">
<t t-name="template_qw_1">
<div>111</div>
</t>
<form t-inherit="template_1_1" t-inherit-mode="extension">
<xpath expr="//span[1]" position="replace">
<article>!!!</article>
</xpath>
</form>
<t t-name="template_qw_2">
<div>222</div>
</t>
<t t-extend="template_qw_1">
<t t-jquery="div" t-operation="after">
<div>333</div>
</t>
</t>
</templates>
"""
})
self.env['ir.asset'].create({
'name': 'test_asset',
'path': url,
'bundle': 'test.bundle',
})
expected = """
<templates xml:space="preserve">
<form t-name="template_1_1" random-attr="gloria">
<article>!!!</article>
<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>
<div>And I learned how to get along</div>
</t>
<t t-name="template_qw_1">
<div>111</div>
</t>
<t t-name="template_qw_2">
<div>222</div>
</t>
<t t-extend="template_qw_1">
<t t-jquery="div" t-operation="after">
<div>333</div>
</t>
</t>
<form t-name="template_2_1" random-attr="gloria">
<span type="Scary screams">Ho !</span>
<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(self.renderBundle(debug=False), expected)
res = self.url_open('/web/webclient/qweb/HASH_BIDON?bundle=test.bundle')
[template] = etree.fromstring(res.text)
self.assertEqual(template.get('t-name'), 'test_template')
self.assertEqual(template[0].get('class'), 'test_div')
@tagged('-standard', 'static_templates_performance')
@tagged('-standard', 'assets_bundle', 'static_templates_performance')
class TestStaticInheritancePerformance(TestStaticInheritanceCommon):
def _sick_script(self, nMod, nFilePerMod, nTemplatePerFile, stepInheritInModule=2, stepInheritPreviousModule=3):
"""
@@ -943,7 +857,7 @@ class TestStaticInheritancePerformance(TestStaticInheritanceCommon):
for m in range(nMod):
for f in range(nFilePerMod):
mname = 'mod_%s' % m
fname = 'mod_%s_file_%s' % (m, f)
fname = 'mod_%s/folder/file_%s.xml' % (m, f)
self.asset_paths.append((fname, mname, 'bundle_1'))
_file = '<templates id="template" xml:space="preserve">'
@@ -991,7 +905,7 @@ class TestStaticInheritancePerformance(TestStaticInheritanceCommon):
}
_file += '</templates>'
self.template_files[fname] = _file.encode()
self.template_files[fname] = _file
self.assertEqual(number_templates, nMod * nFilePerMod * nTemplatePerFile)
def test_static_templates_treatment_linearity(self):
@@ -1000,7 +914,7 @@ class TestStaticInheritancePerformance(TestStaticInheritanceCommon):
self._sick_script(nMod, nFilePerMod, nTemplatePerFile)
before = datetime.now()
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
contents = self.renderBundle(debug=False)
after = datetime.now()
delta2500 = after - before
_logger.runbot('Static Templates Inheritance: 2500 templates treated in %s seconds' % delta2500.total_seconds())
@@ -1013,7 +927,7 @@ class TestStaticInheritancePerformance(TestStaticInheritanceCommon):
self._sick_script(nMod, nFilePerMod, nTemplatePerFile)
before = datetime.now()
HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
self.renderBundle(debug=False)
after = datetime.now()
delta25000 = after - before
-1
View File
@@ -297,7 +297,6 @@
odoo.__session_info__ = <t t-out="json.dumps(session_info)"/>;
odoo.reloadMenus = () => fetch(`/web/webclient/load_menus/${odoo.__session_info__.cache_hashes.load_menus}`).then(res => res.json());
odoo.loadMenusPromise = odoo.reloadMenus();
odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.qweb}?bundle=web.assets_qweb`).then(doc => doc.text());
// Block to avoid leaking variables in the script scope
{
const { user_context, cache_hashes } = odoo.__session_info__;
-15
View File
@@ -494,21 +494,6 @@ class Web_Editor(http.Controller):
return files_data_by_bundle
@http.route("/web_editor/public_render_template", type="json", auth="public", website=True)
def public_render_template(self, args, kwargs): # pylint: disable=unused-argument
# args[0]: xml id of the template to render
# args[1]: optional dict of rendering values, only trusted keys are supported
len_args = len(args)
assert len_args >= 1 and len_args <= 2, 'Need a xmlID and potential rendering values to render a template'
trusted_value_keys = ('debug',)
xmlid = args[0]
values = len_args > 1 and args[1] or {}
View = request.env['ir.ui.view']
return View.render_public_asset(xmlid, {k: values[k] for k in values if k in trusted_value_keys})
@http.route('/web_editor/modify_image/<model("ir.attachment"):attachment>', type="json", auth="user", website=True)
def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=None, original_id=None, mimetype=None):
"""
-4
View File
@@ -6,10 +6,6 @@
<t t-call-assets="web_editor.assets_wysiwyg" t-css="false" />
</template>
<template id="compiled_assets_wysiwyg" name="Wysiwyg Editor" groups="base.group_public,base.group_portal,base.group_user">
<t t-call-assets="web_editor.assets_wysiwyg"/>
</template>
<template id="assets_edit_html_field" name="Wysiwyg Editor Assets for html field (style-inline)" groups="base.group_user,base.group_portal">
<t t-call-assets="web.assets_common" t-js="false"/>
<t t-call-assets="web_editor.assets_wysiwyg" t-js="false"/>
+2 -3
View File
@@ -17,9 +17,6 @@ Odoo Web tours.
'views/tour_views.xml'
],
'assets': {
'web.assets_qweb': [
'web_tour/static/src/debug/tour_dialog_component.xml',
],
'web.assets_common': [
'web_tour/static/src/scss/**/*',
'web_tour/static/src/js/running_tour_action_helper.js',
@@ -28,11 +25,13 @@ Odoo Web tours.
'web_tour/static/src/js/tour_service.js',
'web_tour/static/src/js/tour_step_utils.js',
'web_tour/static/src/js/tour_utils.js',
'/web_tour/static/src/xml/tip.xml',
],
'web.assets_backend': [
'web_tour/static/src/debug/debug_manager.js',
'web_tour/static/src/debug/tour_dialog_component.js',
'web_tour/static/src/services/*.js',
'web_tour/static/src/debug/tour_dialog_component.xml',
],
'web.assets_frontend': [
'web_tour/static/src/js/public/**/*',
-1
View File
@@ -8,7 +8,6 @@ var _t = core._t;
var Tip = Widget.extend({
template: "Tip",
xmlDependencies: ['/web_tour/static/src/xml/tip.xml'],
events: {
click: '_onTipClicked',
mouseenter: '_onMouseEnter',
@@ -7,12 +7,6 @@ odoo.define('web_tour.tour_manager_tests', async function (require) {
const testUtils = require('web.test_utils');
const createView = testUtils.createView;
const ajax = require('web.ajax');
const { qweb } = require('web.core');
// Pre-load the Tip widget template
await ajax.loadXML('/web_tour/static/src/xml/tip.xml', qweb);
/**
* Create a widget and a TourManager instance with a list of given Tour objects.
* @see `TourManager.register()` for more details on the Tours registry system.
+1
View File
@@ -4,3 +4,4 @@
from . import backend
from . import form
from . import main
from . import webclient
+14
View File
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
from odoo.addons.web.controllers.webclient import WebClient
class WebsiteWebClient(WebClient):
@http.route()
def bundle(self, bundle_name, **bundle_params):
if 'website_id' in bundle_params:
request.update_context(website_id=int(bundle_params['website_id']))
return super().bundle(bundle_name, **bundle_params)
+1
View File
@@ -377,6 +377,7 @@ class Http(models.AbstractModel):
'website_id': request.website.id,
'website_company_id': request.website._get_cached('company_id'),
})
session_info['bundle_params']['website_id'] = request.website.id
return session_info
@@ -8,7 +8,7 @@ export default createPublicRoot(WebsiteRoot).then(rootInstance => {
// This data attribute is set by the WebsitePreview client action for a
// restricted editor user.
if (window.frameElement && window.frameElement.dataset.loadWysiwyg === 'true') {
loadWysiwyg(['website.compiled_assets_wysiwyg']).then(() => {
loadWysiwyg(['website.assets_wysiwyg']).then(() => {
window.dispatchEvent(new CustomEvent('PUBLIC-ROOT-READY', {detail: {rootInstance}}));
});
}
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="website.compiled_assets_wysiwyg" name="Website Editor Assets (used in website editor)" groups="website.group_website_restricted_editor">
<t t-call-assets="website.assets_wysiwyg"/>
</template>
</odoo>
+245 -24
View File
@@ -1,9 +1,13 @@
# -*- coding: utf-8 -*-
from contextlib import closing
from collections import OrderedDict
from datetime import datetime
from lxml import etree
from subprocess import Popen, PIPE
import base64
import copy
import hashlib
import io
import itertools
import json
import logging
@@ -20,16 +24,19 @@ except ImportError:
# `sassc` executable in the path.
libsass = None
from odoo import release, SUPERUSER_ID
from odoo import release, SUPERUSER_ID, _
from odoo.http import request
from odoo.modules.module import get_resource_path
from odoo.tools import func, misc, transpile_javascript, is_odoo_module, SourceMapGenerator, profiler
from odoo.tools import (func, misc, transpile_javascript,
is_odoo_module, SourceMapGenerator, profiler,
apply_inheritance_specs)
from odoo.tools.misc import file_open, html_escape as escape
from odoo.tools.pycompat import to_text
_logger = logging.getLogger(__name__)
EXTENSIONS = (".js", ".css", ".scss", ".sass", ".less")
EXTENSIONS = (".js", ".css", ".scss", ".sass", ".less", ".xml")
class CompileError(RuntimeError): pass
def rjsmin(script):
@@ -117,12 +124,14 @@ class AssetsBundle(object):
self.name = name
self.env = request.env if env is None else env
self.javascripts = []
self.templates = []
self.stylesheets = []
self.css_errors = []
self.files = files
self.user_direction = self.env['res.lang']._lang_get(
self.env.context.get('lang') or self.env.user.lang
).direction
# asset-wide html "media" attribute
for f in files:
if css:
@@ -134,8 +143,11 @@ class AssetsBundle(object):
self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
elif f['atype'] == 'text/css':
self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
if js and f['atype'] == 'text/javascript':
self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
if js:
if f['atype'] == 'text/javascript':
self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
elif f['atype'] == 'text/xml':
self.templates.append(XMLAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
def to_node(self, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False):
"""
@@ -185,7 +197,7 @@ class AssetsBundle(object):
"""Returns last modified date of linked files"""
assets = [WebAsset(self, url=f['url'], filename=f['filename'], inline=f['content'])
for f in self.files
if f['atype'] in ['text/sass', "text/scss", "text/less", "text/css", "text/javascript"]]
if f['atype'] in ['text/sass', "text/scss", "text/less", "text/css", "text/javascript", "text/xml"]]
return max(itertools.chain(
(asset.last_modified for asset in assets),
))
@@ -312,7 +324,7 @@ class AssetsBundle(object):
:return the ir.attachment records for a given bundle.
"""
assert extension in ('js', 'min.js', 'js.map', 'css', 'min.css', 'css.map')
assert extension in ('js', 'min.js', 'js.map', 'css', 'min.css', 'css.map', 'xml', 'min.xml')
ira = self.env['ir.attachment']
# Set user direction in name to store two bundles
@@ -322,6 +334,7 @@ class AssetsBundle(object):
fname = '%s.%s' % (self.name, extension)
mimetype = (
'text/css' if extension in ['css', 'min.css'] else
'text/xml' if extension in ['xml', 'min.xml'] else
'application/json' if extension in ['js.map', 'css.map'] else
'application/javascript'
)
@@ -365,31 +378,51 @@ class AssetsBundle(object):
def js(self, is_minified=True):
extension = 'min.js' if is_minified else 'js'
attachments = self.get_attachments(extension)
js_attachment = self.get_attachments(extension)
if not js_attachment:
template_bundle = ''
if self.templates:
content = ['<?xml version="1.0" encoding="UTF-8"?>']
content.append('<templates xml:space="preserve">')
content.append(self.xml(show_inherit_info=not is_minified))
content.append('</templates>')
templates = '\n'.join(content).replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${")
template_bundle = textwrap.dedent(f"""
/*******************************************
* Templates *
*******************************************/
odoo.define('{self.name}.bundle.xml', function(require){{
'use strict';
const {{ loadXML }} = require('@web/core/assets');
const templates = `{templates}`;
return loadXML(templates);
}});""")
if not attachments:
if is_minified:
content = ';\n'.join(asset.minify() for asset in self.javascripts)
return self.save_attachment(extension, content)
content_bundle = ';\n'.join(asset.minify() for asset in self.javascripts)
content_bundle += template_bundle
js_attachment = self.save_attachment(extension, content_bundle)
else:
return self.js_with_sourcemap()
js_attachment = self.js_with_sourcemap(template_bundle=template_bundle)
return attachments[0]
return js_attachment[0]
def js_with_sourcemap(self):
def js_with_sourcemap(self, template_bundle=None):
"""Create the ir.attachment representing the not-minified content of the bundleJS
and create/modify the ir.attachment representing the linked sourcemap.
:return ir.attachment representing the un-minified content of the bundleJS
"""
sourcemap_attachment = self.get_attachments('js.map') \
or self.save_attachment('js.map', '')
or self.save_attachment('js.map', '')
generator = SourceMapGenerator(
source_root="/".join(
[".." for i in range(0, len(self.get_debug_asset_url(name=self.name).split("/")) - 2)]
) + "/",
)
content_bundle_list = []
content_line_count = 0
line_header = 6 # number of lines added by with_header()
@@ -405,7 +438,11 @@ class AssetsBundle(object):
content_bundle_list.append(asset.with_header(asset.content, minimal=False))
content_line_count += len(asset.content.split("\n")) + line_header
content_bundle = ';\n'.join(content_bundle_list) + "\n//# sourceMappingURL=" + sourcemap_attachment.url
content_bundle = ';\n'.join(content_bundle_list)
if template_bundle:
content_bundle += template_bundle
content_bundle += "\n\n//# sourceMappingURL=" + sourcemap_attachment.url
js_attachment = self.save_attachment('js', content_bundle)
generator._file = js_attachment.url
@@ -415,6 +452,138 @@ class AssetsBundle(object):
return js_attachment
def xml(self, show_inherit_info=False):
"""
Create the ir.attachment representing the content of the bundle XML.
The xml contents are loaded and parsed with etree. Inheritances are
applied in the order of files and templates.
Used parsed attributes:
* `t-name`: template name
* `t-inherit`: inherited template name. The template use the
`apply_inheritance_specs` method from `ir.ui.view` to apply
inheritance (with xpath and position).
* 't-inherit-mode': 'primary' to create a new template with the
update, or 'extension' to apply the update on the inherited
template.
* `t-extend` deprecated attribute, used by the JavaScript Qweb.
:param show_inherit_info: if true add the file url and inherit
information in the template.
:return ir.attachment representing the content of the bundle XML
"""
template_dict = OrderedDict()
parser = etree.XMLParser(ns_clean=True, recover=True, remove_comments=True)
for asset in self.templates:
# Load content.
try:
content = asset.content.strip()
template = content if content.startswith('<odoo>') else f'<templates>{asset.content}</templates>'
io_content = io.BytesIO(template.encode('utf-8'))
content_templates_tree = etree.parse(io_content, parser=parser).getroot()
except etree.ParseError as e:
_logger.error("Could not parse file %s: %s", asset.url, e.msg)
raise
addon = asset.url.split('/')[1]
template_dict.setdefault(addon, OrderedDict())
# Process every templates.
for template_tree in list(content_templates_tree):
template_name = None
if 't-name' in template_tree.attrib:
template_name = template_tree.attrib['t-name']
dotted_names = template_name.split('.', 1)
if len(dotted_names) > 1 and dotted_names[0] == addon:
template_name = dotted_names[1]
if 't-inherit' in template_tree.attrib:
inherit_mode = template_tree.attrib.get('t-inherit-mode', 'primary')
if inherit_mode not in ['primary', 'extension']:
raise ValueError(_("Invalid inherit mode. Module %r and template name %r", addon, template_name))
# Get inherited template, the identifier can be "addon.name", just "name" or (silly) "just.name.with.dots"
parent_dotted_name = template_tree.attrib['t-inherit']
split_name_attempt = parent_dotted_name.split('.', 1)
parent_addon, parent_name = split_name_attempt if len(split_name_attempt) == 2 else (addon, parent_dotted_name)
if parent_addon not in template_dict:
if parent_dotted_name in template_dict[addon]:
parent_addon = addon
parent_name = parent_dotted_name
else:
raise ValueError(_("Module %r not loaded or inexistent (try to inherit %r), or templates of addon being loaded %r are misordered (template %r)", parent_addon, parent_name, addon, template_name))
if parent_name not in template_dict[parent_addon]:
raise ValueError(_("Cannot create %r because the template to inherit %r is not found.") % (f'{addon}.{template_name}', f'{parent_addon}.{parent_name}'))
# 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, parent_urls = template_dict[parent_addon][parent_name]
parent_tree = copy.deepcopy(parent_tree)
if show_inherit_info:
# Add inheritance information as xml comment for debugging.
xpaths = []
for item in template_tree:
position = item.get('position')
attrib = dict(**item.attrib)
attrib.pop('position', None)
comment = etree.Comment(f""" Filepath: {asset.url} ; position="{position}" ; {attrib} """)
if position == "attributes":
if item.get('expr'):
comment_node = etree.Element('xpath', {'expr': item.get('expr'), 'position': 'before'})
else:
comment_node = etree.Element(item.tag, item.attrib)
comment_node.attrib['position'] = 'before'
comment_node.append(comment)
xpaths.append(comment_node)
else:
if len(item) > 0:
item[0].addprevious(comment)
else:
item.append(comment)
xpaths.append(item)
else:
xpaths = list(template_tree)
# Apply inheritance.
if inherit_mode == 'primary':
parent_tree.tag = template_tree.tag
inherited_template = apply_inheritance_specs(parent_tree, xpaths)
if inherit_mode == 'primary': # New template_tree: A' = B(A)
for attr_name, attr_val in template_tree.attrib.items():
if attr_name not in ('t-inherit', 't-inherit-mode'):
inherited_template.set(attr_name, attr_val)
if not template_name:
raise ValueError(_("Template name is missing in file %r.", asset.url))
template_dict[addon][template_name] = (inherited_template, parent_urls + [asset.url])
else: # Modifies original: A = B(A)
template_dict[parent_addon][parent_name] = (inherited_template, parent_urls + [asset.url])
elif template_name:
if template_name in template_dict[addon]:
raise ValueError(_("Template %r already exists in module %r", template_name, addon))
template_dict[addon][template_name] = (template_tree, [asset.url])
elif template_tree.attrib.get('t-extend'):
template_name = '%s__extend_%s' % (template_tree.attrib.get('t-extend'), len(template_dict[addon]))
template_dict[addon][template_name] = (template_tree, [asset.url])
else:
raise ValueError(_("Template name is missing in file %r.", asset.url))
# Concat and render inherited templates
root = etree.Element('root')
for addon in template_dict.values():
for template, urls in addon.values():
if show_inherit_info:
tail = "\n"
if len(root) > 0:
tail = root[-1].tail
root[-1].tail = "\n\n"
comment = etree.Comment(f""" Filepath: {' => '.join(urls)} """)
comment.tail = tail
root.append(comment)
root.append(template)
# Returns the string by removing the <root> tag.
return etree.tostring(root, encoding='unicode')[6:-7]
def css(self, is_minified=True):
extension = 'min.css' if is_minified else 'css'
attachments = self.get_attachments(extension)
@@ -719,6 +888,7 @@ class AssetsBundle(object):
error += "This error occurred while compiling the bundle '%s' containing:" % self.name
return error
class WebAsset(object):
html_url_format = '%s'
_content = None
@@ -829,7 +999,7 @@ class JavascriptAsset(WebAsset):
def _fetch_content(self):
try:
return super(JavascriptAsset, self)._fetch_content()
return super()._fetch_content()
except AssetError as e:
return u"console.error(%s);" % json.dumps(to_text(e))
@@ -859,10 +1029,11 @@ class JavascriptAsset(WebAsset):
# * Bundle: <name> *
# * Lines: 42 *
# **************************/
line_count = content.count('\n')
lines = [
f"Filepath: {self.url}",
f"Bundle: {self.bundle.name}",
f"Lines: {len(content.splitlines())}",
f"Lines: {line_count}",
]
length = max(map(len, lines))
return "\n".join([
@@ -874,6 +1045,56 @@ class JavascriptAsset(WebAsset):
])
class XMLAsset(WebAsset):
def _fetch_content(self):
try:
content = super()._fetch_content()
except AssetError as e:
return f'<error data-asset-bundle={self.bundle.name!r} data-asset-version={self.bundle.version!r}>{json.dumps(to_text(e))}</error>'
parser = etree.XMLParser(ns_clean=True, recover=True, remove_comments=True)
root = etree.parse(io.BytesIO(content.encode('utf-8')), parser=parser).getroot()
if root.tag in ('templates', 'template'):
return ''.join(etree.tostring(el, encoding='unicode') for el in root)
return etree.tostring(root, encoding='unicode')
def to_node(self):
attributes = {
'async': 'async',
'defer': 'defer',
'type': 'text/xml',
'data-src': self.html_url,
'data-asset-bundle': self.bundle.name,
'data-asset-version': self.bundle.version,
}
return ("script", attributes, None)
def with_header(self, content=None):
if content is None:
content = self.content
# format the header like
# <!--=========================-->
# <!-- Filepath: <asset_url> -->
# <!-- Bundle: <name> -->
# <!-- Lines: 42 -->
# <!--=========================-->
line_count = content.count('\n')
lines = [
f"Filepath: {self.url}",
f"Bundle: {self.bundle.name}",
f"Lines: {line_count}",
]
length = max(map(len, lines))
return "\n".join([
"",
"<!-- " + "=" * length + " -->",
*(f"<!-- {line:<{length}} -->" for line in lines),
"<!-- " + "=" * length + " -->",
content,
])
class StylesheetAsset(WebAsset):
rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
rx_url = re.compile(r"""(?<!")url\s*\(\s*('|"|)(?!'|"|/|https?://|data:|#{str)""", re.U)
@@ -883,7 +1104,7 @@ class StylesheetAsset(WebAsset):
def __init__(self, *args, **kw):
self.media = kw.pop('media', None)
self.direction = kw.pop('direction', None)
super(StylesheetAsset, self).__init__(*args, **kw)
super().__init__(*args, **kw)
if self.direction == 'rtl' and self.url:
self.html_url_args = self.url.rsplit('.', 1)
self.html_url_format = '%%s/%s/%s.%%s' % ('rtl', self.bundle.name)
@@ -891,14 +1112,14 @@ class StylesheetAsset(WebAsset):
@property
def content(self):
content = super(StylesheetAsset, self).content
content = super().content
if self.media:
content = '@media %s { %s }' % (self.media, content)
return content
def _fetch_content(self):
try:
content = super(StylesheetAsset, self)._fetch_content()
content = super()._fetch_content()
web_dir = os.path.dirname(self.url)
if self.rx_import:
@@ -961,7 +1182,7 @@ class PreprocessedCSS(StylesheetAsset):
rx_import = None
def __init__(self, *args, **kw):
super(PreprocessedCSS, self).__init__(*args, **kw)
super().__init__(*args, **kw)
self.html_url_args = tuple(self.url.rsplit('/', 1))
self.html_url_format = '%%s/%s%s/%%s.css' % ('rtl/' if self.direction == 'rtl' else '', self.bundle.name)
@@ -1030,7 +1251,7 @@ class ScssStylesheetAsset(PreprocessedCSS):
def compile(self, source):
if libsass is None:
return super(ScssStylesheetAsset, self).compile(source)
return super().compile(source)
try:
profiler.force_hook()
+11 -17
View File
@@ -87,7 +87,7 @@ class IrAsset(models.Model):
active = fields.Boolean(string='active', default=True)
sequence = fields.Integer(string="Sequence", default=DEFAULT_SEQUENCE, required=True)
def _get_asset_paths(self, bundle, addons=None, css=False, js=False, xml=False):
def _get_asset_paths(self, bundle, addons=None, css=False, js=False):
"""
Fetches all asset file paths from a given list of addons matching a
certain bundle. The returned list is composed of tuples containing the
@@ -108,8 +108,8 @@ class IrAsset(models.Model):
:param addons: list of addon names as strings. The files returned will
only be contained in the given addons.
:param css: boolean: whether or not to include style files
:param js: boolean: whether or not to include script files
:param xml: boolean: whether or not to include template files
:param js: boolean: whether or not to include script files and template
files
:returns: the list of tuples (path, addon, bundle)
"""
installed = self._get_installed_addons_list()
@@ -117,10 +117,10 @@ class IrAsset(models.Model):
addons = self._get_active_addons_list()
asset_paths = AssetPaths()
self._fill_asset_paths(bundle, addons, installed, css, js, xml, asset_paths, [])
self._fill_asset_paths(bundle, addons, installed, css, js, asset_paths, [])
return asset_paths.list
def _fill_asset_paths(self, bundle, addons, installed, css, js, xml, asset_paths, seen):
def _fill_asset_paths(self, bundle, addons, installed, css, js, asset_paths, seen):
"""
Fills the given AssetPaths instance by applying the operations found in
the matching bundle of the given addons manifests.
@@ -140,10 +140,9 @@ class IrAsset(models.Model):
exts = []
if js:
exts += SCRIPT_EXTENSIONS
exts += TEMPLATE_EXTENSIONS
if css:
exts += STYLE_EXTENSIONS
if xml:
exts += TEMPLATE_EXTENSIONS
# this index is used for prepending: files are inserted at the beginning
# of the CURRENT bundle.
@@ -164,7 +163,7 @@ class IrAsset(models.Model):
"""
if directive == INCLUDE_DIRECTIVE:
# recursively call this function for each INCLUDE_DIRECTIVE directive.
self._fill_asset_paths(path_def, addons, installed, css, js, xml, asset_paths, seen + [bundle])
self._fill_asset_paths(path_def, addons, installed, css, js, asset_paths, seen + [bundle])
return
addon, paths = self._get_paths(path_def, installed, exts)
@@ -235,10 +234,9 @@ class IrAsset(models.Model):
target_path = self._get_paths(target_path_def, installed)[1][0]
css = ext in STYLE_EXTENSIONS
js = ext in SCRIPT_EXTENSIONS
xml = ext in TEMPLATE_EXTENSIONS
js = ext in SCRIPT_EXTENSIONS or ext in TEMPLATE_EXTENSIONS
asset_paths = self._get_asset_paths(root_bundle, css=css, js=js, xml=xml)
asset_paths = self._get_asset_paths(root_bundle, css=css, js=js)
for path, _, bundle in asset_paths:
if path == target_path:
@@ -344,12 +342,8 @@ class IrAsset(models.Model):
paths = list(filter(is_safe_path, paths))
safe_path = safe_path and len_paths == len(paths)
# When fetching template file paths, we need the full paths since xml
# files are read from the file system. But web assets (scripts and
# stylesheets) must be loaded using relative paths, hence the trimming
# for non-xml file paths.
paths = [path if path.split('.')[-1] in TEMPLATE_EXTENSIONS else fs2web(path[len(addons_path):]) for path in paths]
# Web assets must be loaded using relative paths.
paths = [fs2web(path[len(addons_path):]) for path in paths]
else:
addon = None
+33 -13
View File
@@ -385,12 +385,12 @@ from odoo.tools.json import scriptsafe
from odoo.tools.misc import str2bool
from odoo.tools.image import image_data_uri
from odoo.http import request
from odoo.modules.module import get_resource_path
from odoo.modules.module import get_resource_path, get_module_path
from odoo.tools.profiler import QwebTracker
from odoo.exceptions import UserError, AccessDenied, AccessError, MissingError, ValidationError
from odoo.addons.base.models.assetsbundle import AssetsBundle
from odoo.addons.base.models.ir_asset import can_aggregate, STYLE_EXTENSIONS, SCRIPT_EXTENSIONS
from odoo.addons.base.models.ir_asset import can_aggregate, STYLE_EXTENSIONS, SCRIPT_EXTENSIONS, TEMPLATE_EXTENSIONS
_logger = logging.getLogger(__name__)
@@ -2446,13 +2446,13 @@ class IrQWeb(models.AbstractModel):
# in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear
# by restarting the server after updating the source code (or using the "Clear server cache" in debug tools)
'xml' not in tools.config['dev_mode'],
tools.ormcache_context('bundle', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', keys=("website_id", "lang")),
tools.ormcache('bundle', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', 'media', 'tuple(self.env.context.get(k) for k in self._get_template_cache_keys())'),
)
def _generate_asset_nodes_cache(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
return self._generate_asset_nodes(bundle, css, js, debug, async_load, defer_load, lazy_load, media)
@tools.ormcache_context('bundle', 'nodeAttrs and nodeAttrs.get("media")', 'defer_load', 'lazy_load', keys=("website_id", "lang"))
def _get_asset_content(self, bundle, nodeAttrs=None, defer_load=False, lazy_load=False):
@tools.ormcache('bundle', 'defer_load', 'lazy_load', 'media', 'tuple(self.env.context.get(k) for k in self._get_template_cache_keys())')
def _get_asset_content(self, bundle, defer_load=False, lazy_load=False, media=None):
asset_paths = self.env['ir.asset']._get_asset_paths(bundle=bundle, css=True, js=True)
files = []
@@ -2460,11 +2460,24 @@ class IrQWeb(models.AbstractModel):
for path, *_ in asset_paths:
ext = path.split('.')[-1]
is_js = ext in SCRIPT_EXTENSIONS
is_xml = ext in TEMPLATE_EXTENSIONS
is_css = ext in STYLE_EXTENSIONS
if not is_js and not is_css:
if not is_js and not is_xml and not is_css:
continue
mimetype = 'text/javascript' if is_js else f'text/{ext}'
if is_xml:
base = get_module_path(bundle.split('.')[0]).rsplit('/', 1)[0]
if path.startswith(base):
path = path[len(base):]
mimetype = None
if is_js:
mimetype = 'text/javascript'
elif is_css:
mimetype = f'text/{ext}'
elif is_xml:
mimetype = 'text/xml'
if can_aggregate(path):
segments = [segment for segment in path.split('/') if segment]
files.append({
@@ -2472,7 +2485,7 @@ class IrQWeb(models.AbstractModel):
'url': path,
'filename': get_resource_path(*segments) if segments else None,
'content': '',
'media': nodeAttrs and nodeAttrs.get('media'),
'media': media,
})
else:
if is_js:
@@ -2483,15 +2496,23 @@ class IrQWeb(models.AbstractModel):
attributes["data-src" if lazy_load else "src"] = path
if defer_load or lazy_load:
attributes["defer"] = "defer"
else:
elif is_css:
tag = 'link'
attributes = {
"type": mimetype,
"rel": "stylesheet",
"href": path,
'media': nodeAttrs and nodeAttrs.get('media'),
'media': media,
}
remains.append((tag, attributes, ''))
elif is_xml:
tag = 'script'
attributes = {
"type": mimetype,
"async": "async",
"rel": "prefetch",
"data-src": path,
}
remains.append((tag, attributes, None))
return (files, remains)
@@ -2499,8 +2520,7 @@ class IrQWeb(models.AbstractModel):
return AssetsBundle(bundle_name, files, env=env, css=css, js=js)
def _generate_asset_nodes(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
nodeAttrs = {'media': media} if css and media else None
files, remains = self._get_asset_content(bundle, nodeAttrs, defer_load=defer_load, lazy_load=lazy_load)
files, remains = self._get_asset_content(bundle, defer_load=defer_load, lazy_load=lazy_load, media=css and media or None)
asset = self._get_asset_bundle(bundle, files, env=self.env, css=css, js=js)
remains = [node for node in remains if (css and node[0] == 'link') or (js and node[0] == 'script')]
return remains + asset.to_node(css=css, js=js, debug=debug, async_load=async_load, defer_load=defer_load, lazy_load=lazy_load)
@@ -1773,7 +1773,7 @@ class TestAssetsManifest(AddonManifestPatched):
'path': '/test_assetsbundle/%s' % path_to_dummy,
})
files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules), xml=False)
files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules))
self.assertFalse(files)
def test_33(self):
@@ -1822,7 +1822,7 @@ class TestAssetsManifest(AddonManifestPatched):
'bundle': 'test_assetsbundle.irassetsec',
'path': '/test_assetsbundle/data/ir_asset.xml',
})
files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules), xml=False)
files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules))
self.assertFalse(files)
def test_36(self):
@@ -1831,7 +1831,7 @@ class TestAssetsManifest(AddonManifestPatched):
'bundle': 'test_assetsbundle.irassetsec',
'path': '/test_assetsbundle/static/accessible.xml',
})
files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules), xml=False)
files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules))
self.assertEqual(len(files), 1)
self.assertTrue('test_assetsbundle/static/accessible.xml' in files[0][0])
+1 -1
View File
@@ -534,7 +534,7 @@ class Stream:
'conditional': self.conditional,
'etag': self.etag,
'last_modified': self.last_modified,
'max_age': self.max_age,
'max_age': STATIC_CACHE_LONG if immutable else self.max_age,
'environ': request.httprequest.environ,
'response_class': Response,
**send_file_kwargs,