[IMP] *: Improve assets management

This commit changes the way assets are declared in Odoo modules.

Before: assets were declared in template files. Template bundles were
generated from primary templates, so technically any qweb template could
have been called as an asset bundle, with the 't-call-assets' directive.

Being standard qweb templates, they had access to standard HTML tags
(script, link, with or without raw scripts or style definition), qweb
directives (t-call, t-raw, etc.) and could be inherited by other
templates.

Now: assets are defined in the module's manifest and generated by the
't-call-assets' directive.

More information on the new system can be found on the updated user
documentation (see the "JavaScript Reference" section).

Task: 2352566

Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Raphael Collet <rco@odoo.com>
Co-authored-by: Simon Genin <ges@odoo.com>
This commit is contained in:
Julien Mougenot
2021-03-31 13:57:17 +02:00
co-authored by Bruno Boi Lucas Perais Mathieu Duckerts-Antoine Raphael Collet Simon Genin
parent ee412d2138
commit 8cc066173d
45 changed files with 2553 additions and 591 deletions
+2 -2
View File
@@ -29,8 +29,8 @@ odoo.define('bus.WebClient', function (require) {
*/
show_application() {
const shown = this._super(...arguments);
document.querySelectorAll('*[data-asset-xmlid]').forEach(el => {
this._assets[el.getAttribute('data-asset-xmlid')] = el.getAttribute('data-asset-version');
document.querySelectorAll('*[data-asset-bundle]').forEach(el => {
this._assets[el.getAttribute('data-asset-bundle')] = el.getAttribute('data-asset-version');
});
this.call('bus_service', 'onNotification', this, this._onNotification);
this.call('bus_service', 'addChannel', 'bundle_changed');
+38 -100
View File
@@ -33,14 +33,14 @@ import unicodedata
import odoo
import odoo.modules.registry
from odoo.api import call_kw, Environment
from odoo.modules import get_module_path, get_resource_path
from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property, float_repr, osutil
from odoo.modules import get_module_path, get_resource_path, module
from odoo.tools import image_process, html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property, float_repr, osutil
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.translate import _
from odoo.tools.misc import str2bool, xlsxwriter, file_open
from odoo.tools.safe_eval import safe_eval, time
from odoo import http, tools
from odoo.http import content_disposition, dispatch_rpc, request, serialize_exception as _serialize_exception, Response
from odoo.http import content_disposition, dispatch_rpc, request, serialize_exception as _serialize_exception
from odoo.exceptions import AccessError, UserError, AccessDenied
from odoo.models import check_method_name
from odoo.service import db, security
@@ -183,84 +183,10 @@ def ensure_db(redirect='/web/database/selector'):
request.session.db = db
def module_installed(environment):
# Candidates module the current heuristic is the /static dir
loadable = list(http.addons_manifest)
# Retrieve database installed modules
# TODO The following code should move to ir.module.module.list_installed_modules()
Modules = environment['ir.module.module']
domain = [('state','=','installed'), ('name','in', loadable)]
modules = OrderedDict(
(module.name, module.dependencies_id.mapped('name'))
for module in Modules.search(domain)
)
sorted_modules = topological_sort(modules)
return sorted_modules
def module_installed_bypass_session(dbname):
try:
registry = odoo.registry(dbname)
with registry.cursor() as cr:
return module_installed(
environment=Environment(cr, odoo.SUPERUSER_ID, {}))
except Exception:
pass
return {}
def module_boot(db=None):
server_wide_modules = odoo.conf.server_wide_modules or []
serverside = ['base', 'web']
dbside = []
for i in server_wide_modules:
if i in http.addons_manifest and i not in serverside:
serverside.append(i)
monodb = db or db_monodb()
if monodb:
dbside = module_installed_bypass_session(monodb)
dbside = [i for i in dbside if i not in serverside]
addons = serverside + dbside
return addons
def fs2web(path):
"""convert FS path into web path"""
return '/'.join(path.split(os.path.sep))
def manifest_glob(extension, addons=None, db=None, include_remotes=False):
if addons is None:
addons = module_boot(db=db)
r = []
for addon in addons:
manifest = http.addons_manifest.get(addon, None)
if not manifest:
continue
# ensure does not ends with /
addons_path = os.path.join(manifest['addons_path'], '')[:-1]
globlist = manifest.get(extension, [])
for pattern in globlist:
if pattern.startswith(('http://', 'https://', '//')):
if include_remotes:
r.append((None, pattern, addon))
else:
for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
r.append((path, fs2web(path[len(addons_path):]), addon))
return r
def manifest_list(extension, mods=None, db=None, debug=None):
""" list resources to load specifying either:
mods: a comma separated string listing modules
db: a database name (return all installed modules in that database)
"""
if debug is not None:
_logger.warning("odoo.addons.web.main.manifest_list(): debug parameter is deprecated")
mods = mods.split(',')
files = manifest_glob(extension, addons=mods, db=db, include_remotes=True)
return [wp for _fp, wp, addon in files]
def get_last_modified(files):
""" Returns the modification time of the most recently modified
file provided
@@ -584,18 +510,13 @@ class HomeStaticTemplateHelpers(object):
if re.match(COMMENT_PATTERN, comment.text.strip()):
comment.getparent().remove(comment)
def _manifest_glob(self):
'''Proxy for manifest_glob
Usefull to make 'self' testable'''
return manifest_glob('qweb', self.addons, self.db)
def _read_addon_file(self, file_path):
"""Reads the content of a file given by file_path
Usefull to make 'self' testable
:param str file_path:
:returns: str
"""
with open(file_path, 'rb') as fp:
with file_open(file_path, 'rb') as fp:
contents = fp.read()
return contents
@@ -621,7 +542,7 @@ class HomeStaticTemplateHelpers(object):
xml = self._compute_xml_tree(addon, fname, contents)
if root is None:
root = etree.Element(xml.tag)
root = etree.Element('templates')
for addon in self.template_dict.values():
for template in addon.values():
@@ -629,22 +550,34 @@ class HomeStaticTemplateHelpers(object):
return etree.tostring(root, encoding='utf-8') if root is not None else b'', checksum.hexdigest()[:64]
def _get_asset_paths(self):
"""Proxy for ir_asset._get_asset_paths
Useful to make 'self' testable.
"""
return request.env['ir.asset']._get_asset_paths(addons=self.addons, bundle='web.assets_qweb', xml=True)
def _get_qweb_templates(self):
"""One and only entry point that gets and evaluates static qweb templates
:rtype: (str, str)
"""
files = OrderedDict([(addon, list()) for addon in self.addons])
[files[f[2]].append(f[0]) for f in self._manifest_glob()]
content, checksum = self._concat_xml(files)
xml_paths = defaultdict(list)
# group paths by module, keeping them in order
for path, addon, _ in self._get_asset_paths():
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, db=None, debug=False):
def get_qweb_templates_checksum(cls, addons=None, db=None, debug=False):
return cls(addons, db, checksum_only=True, debug=debug)._get_qweb_templates()[1]
@classmethod
def get_qweb_templates(cls, addons, db=None, debug=False):
def get_qweb_templates(cls, addons=None, db=None, debug=False):
return cls(addons, db, debug=debug)._get_qweb_templates()[0]
@@ -979,14 +912,6 @@ class Home(http.Controller):
class WebClient(http.Controller):
@http.route('/web/webclient/csslist', type='json', auth="none")
def csslist(self, mods=None):
return manifest_list('css', mods=mods)
@http.route('/web/webclient/jslist', type='json', auth="none")
def jslist(self, mods=None):
return manifest_list('js', mods=mods)
@http.route('/web/webclient/locale/<string:lang>', type='http', auth="none")
def load_locale(self, lang):
magic_file_finding = [lang.replace("_", '-').lower(), lang.split('_')[0]]
@@ -1011,6 +936,10 @@ class WebClient(http.Controller):
@http.route('/web/webclient/qweb/<string:unique>', type='http', auth="none", cors="*")
def qweb(self, unique, mods=None, db=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)
return request.make_response(content, [
@@ -1019,7 +948,7 @@ class WebClient(http.Controller):
])
@http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
def bootstrap_translations(self, mods):
def bootstrap_translations(self, mods=None):
""" Load local translations from *.po files, as a temporary solution
until we have established a valid session. This is meant only
for translating the login page and db management chrome, using
@@ -1031,9 +960,15 @@ class WebClient(http.Controller):
request.session._fix_lang(context)
lang = context['lang'].split('_')[0]
if mods is None:
mods = odoo.conf.server_wide_modules or []
if request.db:
mods = request.env.registry._init_modules | set(mods)
translations_per_module = {}
for addon_name in mods:
if http.addons_manifest[addon_name].get('bootstrap'):
manifest = http.addons_manifest.get(addon_name)
if manifest and manifest.get('bootstrap'):
addons_path = http.addons_manifest[addon_name]['addons_path']
f_name = os.path.join(addons_path, addon_name, "i18n", lang + ".po")
if not os.path.exists(f_name):
@@ -1057,6 +992,9 @@ class WebClient(http.Controller):
if mods:
mods = mods.split(',')
elif mods is None:
mods = list(request.env.registry._init_modules) + (odoo.conf.server_wide_modules or [])
translations_per_module, lang_params = request.env["ir.translation"].get_translations_for_webclient(mods, lang)
body = json.dumps({
@@ -1285,7 +1223,7 @@ class Session(http.Controller):
@http.route('/web/session/modules', type='json', auth="user")
def modules(self):
# return all installed modules. Web client is smart enough to not load a module twice
return module_installed(environment=request.env(user=odoo.SUPERUSER_ID))
return request.env.registry._init_modules | set([module.current_test] if module.current_test else [])
@http.route('/web/session/save_session_action', type='json', auth="user")
def save_session_action(self, the_action):
+5 -3
View File
@@ -7,7 +7,7 @@ from odoo import api, models
from odoo.http import request
from odoo.tools import ustr
from odoo.addons.web.controllers.main import module_boot, HomeStaticTemplateHelpers
from odoo.addons.web.controllers.main import HomeStaticTemplateHelpers
import odoo
@@ -48,8 +48,10 @@ class Http(models.AbstractModel):
# but is still included in some other calls (e.g. '/web/session/authenticate')
# to avoid access errors and unnecessary information, it is only included for users
# with access to the backend ('internal'-type users)
mods = module_boot()
qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(addons=mods, debug=request.session.debug)
mods = odoo.conf.server_wide_modules or []
if request.db:
mods = list(request.registry._init_modules) + mods
qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug)
lang = user_context.get("lang")
translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang)
menu_json_utf8 = json.dumps(request.env['ir.ui.menu'].load_menus(request.session.debug), default=ustr, sort_keys=True).encode()
+8 -30
View File
@@ -28,7 +28,6 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
mixins.EventDispatcherMixin.init.call(this);
this.setParent(parent);
options = options || {};
this.module_list = (options.modules && options.modules.slice()) || (window.odoo._modules && window.odoo._modules.slice()) || [];
this.server = null;
this.avoid_recursion = false;
this.use_cors = options.use_cors || false;
@@ -79,21 +78,20 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
var self = this;
var prom = this.session_reload();
if (this.is_frontend) {
if (this.is_frontend || this.is_report) {
return prom.then(function () {
return self.load_translations();
});
}
return prom.then(function () {
var modules = self.module_list.join(',');
var promise = self.load_qweb(modules);
var promise = self.load_qweb();
if (self.session_is_valid()) {
return promise.then(function () { return self.load_modules(); });
}
return Promise.all([
promise,
self.rpc('/web/webclient/bootstrap_translations', {mods: self.module_list})
self.rpc('/web/webclient/bootstrap_translations')
.then(function (trans) {
_t.database.set_bundle(trans);
})
@@ -182,35 +180,21 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
* Load additional web addons of that instance and init them
*
*/
load_modules: function () {
var self = this;
var modules = odoo._modules;
var all_modules = _.uniq(self.module_list.concat(modules));
var to_load = _.difference(modules, self.module_list).join(',');
this.module_list = all_modules;
var loaded = Promise.resolve(self.load_translations());
var locale = "/web/webclient/locale/" + self.user_context.lang || 'en_US';
var file_list = [ locale ];
if(to_load.length) {
loaded = Promise.all([
loaded,
self.rpc('/web/webclient/csslist', {mods: to_load})
.then(self.load_css.bind(self)),
self.load_qweb(to_load),
self.rpc('/web/webclient/jslist', {mods: to_load})
.then(function (files) {
file_list = file_list.concat(files);
})
]);
}
return loaded.then(function () {
return self.load_js(file_list);
}).then(function () {
self._configureLocale();
});
},
load_translations: function () {
load_translations: function (modules=null) {
/* We need to get the website lang at this level.
The only way is to get it is to take the HTML tag lang
Without it, we will always send undefined if there is no lang
@@ -219,13 +203,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
htmlLang = (html.getAttribute('lang') || 'en_US').replace('-', '_'),
lang = this.user_context.lang || htmlLang;
return _t.database.load_translations(this, this.module_list, lang, this.translationURL);
},
load_css: function (files) {
var self = this;
_.each(files, function (file) {
ajax.loadCSS(self.url(file, null));
});
return _t.database.load_translations(this, modules, lang, this.translationURL);
},
load_js: function (files) {
var self = this;
@@ -243,7 +221,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
var self = this;
var lock = this.qweb_mutex.exec(function () {
var cacheId = self.cache_hashes && self.cache_hashes.qweb;
var route = '/web/webclient/qweb/' + (cacheId ? cacheId : Date.now()) + '?mods=' + mods;
var route = '/web/webclient/qweb/' + (cacheId ? cacheId : Date.now());
return $.get(route).then(function (doc) {
if (!doc) { return; }
const owlTemplates = [];
+8 -4
View File
@@ -66,10 +66,14 @@ var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase#
var cacheId = session.cache_hashes && session.cache_hashes.translations;
url = url || '/web/webclient/translations';
url += '/' + (cacheId ? cacheId : Date.now());
return $.get(url, {
mods: modules ? modules.join(',') : null,
lang: lang || null,
}).then(function (trans) {
const paramsGet = {};
if (modules) {
paramsGet.modules = modules.join(',');
}
if (lang) {
paramsGet.lang = lang;
}
return $.get(url, paramsGet).then(function (trans) {
self.set_bundle(trans);
});
}
+1 -2
View File
@@ -2,9 +2,8 @@ odoo.define('web.session', function (require) {
"use strict";
var Session = require('web.Session');
var modules = odoo._modules;
var session = new Session(undefined, undefined, {modules: modules, use_cors: false});
var session = new Session(undefined, undefined, {use_cors: false});
session.is_bound = session.session_bind();
return session;
+2 -2
View File
@@ -16,8 +16,8 @@ class WebSuite(odoo.tests.HttpCase):
def test_check_suite(self):
# verify no js test is using `QUnit.only` as it forbid any other test to be executed
self._check_only_call('web.qunit_suite_tests')
self._check_only_call('web.qunit_mobile_suite_tests')
self._check_only_call('qunit_suite_tests')
self._check_only_call('qunit_mobile_suite_tests')
def _check_only_call(self, suite):
# As we currently aren't in a request context, we can't render `web.layout`.
+50 -50
View File
@@ -51,9 +51,9 @@ class TestStaticInheritanceCommon(BaseCase):
def setUp(self):
super(TestStaticInheritanceCommon, self).setUp()
# output is "manifest_glob" return
self.modules = [
('module_1_file_1', None, 'module_1'),
('module_2_file_1', None, 'module_2'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
('module_2_file_1', 'module_2', 'bundle_1'),
]
self.template_files = {
@@ -111,18 +111,18 @@ class TestStaticInheritanceCommon(BaseCase):
# Private methods
def _get_module_names(self):
return ','.join([glob[2] for glob in self.modules])
return ','.join([glob[1] for glob in self.asset_paths])
def _set_patchers(self):
def _patched_for_manifest_glob(*args, **kwargs):
def _patched_for_get_asset_paths(*args, **kwargs):
# Ordered by module
return self.modules
return self.asset_paths
def _patch_for_read_addon_file(*args, **kwargs):
return self.template_files[args[1]]
self.patchers = [
patch.object(HomeStaticTemplateHelpers, '_manifest_glob', _patched_for_manifest_glob),
patch.object(HomeStaticTemplateHelpers, '_get_asset_paths', _patched_for_get_asset_paths),
patch.object(HomeStaticTemplateHelpers, '_read_addon_file', _patch_for_read_addon_file),
]
@@ -178,8 +178,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
'''
}
self.modules = [
('module_1_file_1', None, 'module_1'),
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"""
@@ -218,8 +218,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
</templates>
'''
}
self.modules = [
('module_1_file_1', None, 'module_1'),
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"""
@@ -243,9 +243,9 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_static_inheritance_in_same_module(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
('module_1_file_2', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
('module_1_file_2', 'module_1', 'bundle_1'),
]
self.template_files = {
@@ -286,8 +286,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_static_inheritance_in_same_file(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
@@ -323,8 +323,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_static_inherit_extended_template(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b'''
@@ -367,10 +367,10 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_sibling_extension(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
('module_2_file_1', None, 'module_2'),
('module_3_file_1', None, 'module_3'),
self.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'''
@@ -420,7 +420,7 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_static_misordered_modules(self):
self.modules.reverse()
self.asset_paths.reverse()
with self.assertRaises(ValueError) as ve:
HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
@@ -454,8 +454,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
"""
Replacing a template's meta definition in place doesn't keep the original attrs of the template
"""
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -484,8 +484,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_replace_in_debug_mode2(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -525,8 +525,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
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.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -565,8 +565,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
"""
Root node IS targeted by //NODE_TAG in xpath
"""
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -603,8 +603,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
Root node IS targeted by //NODE_TAG in xpath
"""
self.maxDiff = None
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -642,8 +642,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
The inheriting template has got both its own defining attrs
and new ones if one is to replace its defining root node
"""
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -680,8 +680,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
def test_replace_in_nodebug_mode1(self):
"""Comments already in the arch are ignored"""
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -717,8 +717,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_inherit_from_dotted_tname_1(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -754,8 +754,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_inherit_from_dotted_tname_2(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -791,8 +791,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_inherit_from_dotted_tname_2bis(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -828,8 +828,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_inherit_from_dotted_tname_2ter(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
self.asset_paths = [
('module_1_file_1', 'module_1', 'bundle_1'),
]
self.template_files = {
'module_1_file_1': b"""
@@ -865,9 +865,9 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
self.assertXMLEqual(contents, expected)
def test_inherit_from_dotted_tname_3(self):
self.modules = [
('module_1_file_1', None, 'module_1'),
('module_2_file_1', None, 'module_2'),
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"""
@@ -916,14 +916,14 @@ class TestStaticInheritancePerformance(TestStaticInheritanceCommon):
nMod modules
each module: has nFilesPerModule files, each of which contains nTemplatePerFile templates
"""
self.modules = []
self.asset_paths = []
self.template_files = {}
number_templates = 0
for m in range(nMod):
for f in range(nFilePerMod):
mname = 'mod_%s' % m
fname = 'mod_%s_file_%s' % (m, f)
self.modules.append((fname, None, mname))
self.asset_paths.append((fname, mname, 'bundle_1'))
_file = '<templates id="template" xml:space="preserve">'
+6
View File
@@ -48,6 +48,12 @@
<meta name="viewport" content="initial-scale=1"/>
<title><t t-esc="title or 'Odoo Report'"/></title>
<t t-call-assets="web.report_assets_common" t-js="false"/>
<!--The following is kind of a hack-->
<!--in an iframe, we don't want the session to perform qweb_templates rpc-->
<script type="text/javascript">
window.odoo = {};
window.odoo.session_info = {is_report: true};
</script>
<t t-call-assets="web.assets_common" t-css="false"/>
<t t-call-assets="web.report_assets_common" t-css="false"/>
</head>
+15 -16
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import io
import json
import logging
import re
import time
@@ -355,7 +356,8 @@ class Web_Editor(http.Controller):
url_infos = dict()
for v in views:
for asset_call_node in etree.fromstring(v["arch"]).xpath("//t[@t-call-assets]"):
if asset_call_node.get(resources_type_info['t_call_assets_attribute']) == "false":
attr = asset_call_node.get(resources_type_info['t_call_assets_attribute'])
if attr and not json.loads(attr.lower()):
continue
asset_name = asset_call_node.get("t-call-assets")
@@ -386,10 +388,7 @@ class Web_Editor(http.Controller):
# scss data is returned sorted by bundle, with the bundles
# names and xmlids
if len(files_data):
files_data_by_bundle.append([
{'xmlid': asset_name, 'name': request.env.ref(asset_name).name},
files_data
])
files_data_by_bundle.append([asset_name, files_data])
# Filter bundles/files:
# - A file which appears in multiple bundles only appears in the
@@ -400,8 +399,8 @@ class Web_Editor(http.Controller):
bundle_1 = files_data_by_bundle[i]
for j in range(0, len(files_data_by_bundle)):
bundle_2 = files_data_by_bundle[j]
# In unwanted bundles, keep only the files which are in wanted bundles too (_assets_helpers)
if bundle_1[0]["xmlid"] not in bundles_restriction and bundle_2[0]["xmlid"] in bundles_restriction:
# In unwanted bundles, keep only the files which are in wanted bundles too (web._helpers)
if bundle_1[0] not in bundles_restriction and bundle_2[0] in bundles_restriction:
bundle_1[1] = [item_1 for item_1 in bundle_1[1] if item_1 in bundle_2[1]]
for i in range(0, len(files_data_by_bundle)):
bundle_1 = files_data_by_bundle[i]
@@ -414,7 +413,7 @@ class Web_Editor(http.Controller):
# Only keep bundles which still have files and that were requested
files_data_by_bundle = [
data for data in files_data_by_bundle
if (len(data[1]) > 0 and (not bundles_restriction or data[0]["xmlid"] in bundles_restriction))
if (len(data[1]) > 0 and (not bundles_restriction or data[0] in bundles_restriction))
]
# Fetch the arch of each kept file, in each bundle
@@ -439,7 +438,7 @@ class Web_Editor(http.Controller):
return files_data_by_bundle
@http.route("/web_editor/save_asset", type="json", auth="user", website=True)
def save_asset(self, url, bundle_xmlid, content, file_type):
def save_asset(self, url, bundle, content, file_type):
"""
Save a given modification of a scss/js file.
@@ -447,18 +446,18 @@ class Web_Editor(http.Controller):
url (str):
the original url of the scss/js file which has to be modified
bundle_xmlid (str):
the xmlid of the bundle in which the scss/js file addition can
bundle (str):
the name of the bundle in which the scss/js file addition can
be found
content (str): the new content of the scss/js file
file_type (str): 'scss' or 'js'
"""
request.env['web_editor.assets'].save_asset(url, bundle_xmlid, content, file_type)
request.env['web_editor.assets'].save_asset(url, bundle, content, file_type)
@http.route("/web_editor/reset_asset", type="json", auth="user", website=True)
def reset_asset(self, url, bundle_xmlid):
def reset_asset(self, url, bundle):
"""
The reset_asset route is in charge of reverting all the changes that
were done to a scss/js file.
@@ -467,11 +466,11 @@ class Web_Editor(http.Controller):
url (str):
the original URL of the scss/js file to reset
bundle_xmlid (str):
the xmlid of the bundle in which the scss/js file addition can
bundle (str):
the name of the bundle in which the scss/js file addition can
be found
"""
request.env['web_editor.assets'].reset_asset(url, bundle_xmlid)
request.env['web_editor.assets'].reset_asset(url, bundle)
@http.route("/web_editor/public_render_template", type="json", auth="public", website=True)
def public_render_template(self, args):
+32 -65
View File
@@ -124,25 +124,25 @@ class Assets(models.AbstractModel):
parts = url.rsplit(".", 1)
return "%s.custom.%s.%s" % (parts[0], bundle_xmlid, parts[1])
def reset_asset(self, url, bundle_xmlid):
def reset_asset(self, url, bundle):
"""
Delete the potential customizations made to a given (original) asset.
Params:
url (str): the URL of the original asset (scss / js) file
bundle_xmlid (str):
bundle (str):
the name of the bundle in which the customizations to delete
were made
"""
custom_url = self.make_custom_asset_file_url(url, bundle_xmlid)
custom_url = self.make_custom_asset_file_url(url, bundle)
# Simply delete the attachement which contains the modified scss/js file
# and the xpath view which links it
self._get_custom_attachment(custom_url).unlink()
self._get_custom_view(custom_url).unlink()
self._get_custom_asset(custom_url).unlink()
def save_asset(self, url, bundle_xmlid, content, file_type):
def save_asset(self, url, bundle, content, file_type):
"""
Customize the content of a given asset (scss / js).
@@ -151,7 +151,7 @@ class Assets(models.AbstractModel):
the URL of the original asset to customize (whether or not the
asset was already customized)
bundle_xmlid (src):
bundle (src):
the name of the bundle in which the customizations will take
effect
@@ -160,7 +160,7 @@ class Assets(models.AbstractModel):
file_type (src):
either 'scss' or 'js' according to the file being customized
"""
custom_url = self.make_custom_asset_file_url(url, bundle_xmlid)
custom_url = self.make_custom_asset_file_url(url, bundle)
datas = base64.b64encode((content or "\n").encode("utf-8"))
# Check if the file to save had already been modified
@@ -179,49 +179,27 @@ class Assets(models.AbstractModel):
'datas': datas,
'url': custom_url,
}
new_attach.update(self._save_asset_attachment_hook())
new_attach.update(self._save_asset_hook())
self.env["ir.attachment"].create(new_attach)
# Create a view to extend the template which adds the original file
# to link the new modified version instead
file_type_info = {
'tag': 'link' if file_type == 'scss' else 'script',
'attribute': 'href' if file_type == 'scss' else 'src',
# Create an asset with the new attachment
IrAsset = self.env['ir.asset']
new_asset = {
'glob': custom_url,
'target': url,
'directive': 'replace',
**self._save_asset_hook(),
}
def views_linking_url(view):
"""
Returns whether the view arch has some html tag linked to
the url. (note: searching for the URL string is not enough as it
could appear in a comment or an xpath expression.)
"""
tree = etree.XML(view.arch)
return bool(tree.xpath("//%%(tag)s[@%%(attribute)s='%(url)s']" % {
'url': url,
} % file_type_info))
IrUiView = self.env["ir.ui.view"]
view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(views_linking_url)
new_view = {
'name': custom_url,
'key': 'web_editor.%s_%s' % (file_type, str(uuid.uuid4())[:6]),
'mode': "extension",
'inherit_id': view_to_xpath.id,
'arch': """
<data inherit_id="%(inherit_xml_id)s" name="%(name)s">
<xpath expr="//%%(tag)s[@%%(attribute)s='%(url_to_replace)s']" position="attributes">
<attribute name="%%(attribute)s">%(new_url)s</attribute>
</xpath>
</data>
""" % {
'inherit_xml_id': view_to_xpath.xml_id,
'name': custom_url,
'url_to_replace': url,
'new_url': custom_url,
} % file_type_info
}
new_view.update(self._save_asset_view_hook())
IrUiView.create(new_view)
target_asset = self._get_custom_asset(url)
if target_asset:
new_asset['name'] = target_asset.name + ' override'
new_asset['bundle'] = target_asset.bundle
new_asset['sequence'] = target_asset.sequence
else:
path_parts = '/'.join(os.path.split(custom_url)).split('/')
new_asset['name'] = '%s: replace %s' % (bundle, path_parts[-1])
new_asset['bundle'] = IrAsset._get_related_bundle(url, bundle)
IrAsset.create(new_asset)
self.env["ir.qweb"].clear_caches()
@@ -239,35 +217,24 @@ class Assets(models.AbstractModel):
assert op in ('in', '='), 'Invalid operator'
return self.env["ir.attachment"].search([("url", op, custom_url)])
def _get_custom_view(self, custom_url, op='='):
def _get_custom_asset(self, custom_url):
"""
Fetch the ir.ui.view record related to the given customized asset (the
Fetch the ir.asset record related to the given customized asset (the
inheriting view which replace the original asset by the customized one).
Params:
custom_url (str): the URL of the customized asset
op (str, default: '='): the operator to use to search the records
Returns:
ir.ui.view()
ir.asset()
"""
assert op in ('='), 'Invalid operator'
return self.env["ir.ui.view"].search([("name", op, custom_url)])
url = custom_url[1:] if custom_url.startswith(('/', '\\')) else custom_url
return self.env['ir.asset'].search([('glob', 'like', url)])
def _save_asset_attachment_hook(self):
def _save_asset_hook(self):
"""
Returns the additional values to use to write the DB on customized
attachment creation.
Returns:
dict
"""
return {}
def _save_asset_view_hook(self):
"""
Returns the additional values to use to write the DB on customized
asset's related view creation.
attachment and asset creation.
Returns:
dict
@@ -520,7 +520,7 @@ var ViewEditor = Widget.extend({
// Store the URL ungrouped by bundle and use the URL as key (resource ID)
var resources = type === 'scss' ? this.scss : this.js;
_.each(data, function (bundleInfos) {
_.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; });
_.each(bundleInfos[1], function (info) { info.bundle = bundleInfos[0]; });
_.extend(resources, _.indexBy(bundleInfos[1], 'url'));
});
}
@@ -548,7 +548,7 @@ var ViewEditor = Widget.extend({
route: '/web_editor/reset_asset',
params: {
url: resID,
bundle_xmlid: resource.bundle_xmlid,
bundle: resource.bundle,
},
});
}
@@ -565,13 +565,13 @@ var ViewEditor = Widget.extend({
_saveSCSSorJS: function (session) {
var self = this;
var sessionIdEndsWithJS = _.string.endsWith(session.id, '.js');
var bundleXmlID = sessionIdEndsWithJS ? this.js[session.id].bundle_xmlid : this.scss[session.id].bundle_xmlid;
var bundle = sessionIdEndsWithJS ? this.js[session.id].bundle : this.scss[session.id].bundle;
var fileType = sessionIdEndsWithJS ? 'js' : 'scss';
return self._rpc({
route: '/web_editor/save_asset',
params: {
url: session.id,
bundle_xmlid: bundleXmlID,
bundle,
content: session.text,
file_type: fileType,
},
@@ -806,7 +806,7 @@ var ViewEditor = Widget.extend({
function _populateList(sortedData, $list, lettersToRemove) {
_.each(sortedData, function (bundleInfos) {
var $optgroup = $('<optgroup/>', {
label: bundleInfos[0].name,
label: bundleInfos[0],
}).appendTo($list);
_.each(bundleInfos[1], function (dataInfo) {
var name = dataInfo.url.substring(_.lastIndexOf(dataInfo.url, '/') + 1, dataInfo.url.length - lettersToRemove);
@@ -1,5 +1,5 @@
/** @odoo-module alias=web_editor.wysiwyg.root.test */
import WysiwygRoot from "web_editor.wysiwyg.root";
import * as WysiwygRoot from "web_editor.wysiwyg.root";
WysiwygRoot.include({
assetLibs: null // We need to add the asset because tests performed overwrites (Dialog, Unbreakable...)
+1
View File
@@ -83,6 +83,7 @@
'views/res_config_settings_views.xml',
'views/website_rewrite.xml',
'views/ir_actions_views.xml',
'views/ir_asset_views.xml',
'views/ir_attachment_views.xml',
'views/res_partner_views.xml',
'wizard/base_language_install_views.xml',
+1
View File
@@ -3,6 +3,7 @@
from . import assets
from . import ir_actions
from . import ir_asset
from . import ir_attachment
from . import ir_http
from . import ir_module_module
+6 -18
View File
@@ -61,33 +61,21 @@ class Assets(models.AbstractModel):
res = super(Assets, self)._get_custom_attachment(custom_url, op=op)
return res.with_context(website_id=website.id).filtered(lambda x: not x.website_id or x.website_id == website)
def _get_custom_view(self, custom_url, op='='):
def _get_custom_asset(self, custom_url):
"""
See web_editor.Assets._get_custom_view
See web_editor.Assets._get_custom_asset
Extend to only return the views related to the current website.
"""
website = self.env['website'].get_current_website()
res = super(Assets, self)._get_custom_view(custom_url, op=op)
res = super(Assets, self)._get_custom_asset(custom_url)
return res.with_context(website_id=website.id).filter_duplicate()
def _save_asset_attachment_hook(self):
def _save_asset_hook(self):
"""
See web_editor.Assets._save_asset_attachment_hook
See web_editor.Assets._save_asset_hook
Extend to add website ID at attachment creation.
"""
res = super(Assets, self)._save_asset_attachment_hook()
website = self.env['website'].get_current_website()
if website:
res['website_id'] = website.id
return res
def _save_asset_view_hook(self):
"""
See web_editor.Assets._save_asset_view_hook
Extend to add website ID at view creation.
"""
res = super(Assets, self)._save_asset_view_hook()
res = super(Assets, self)._save_asset_hook()
website = self.env['website'].get_current_website()
if website:
+60
View File
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class IrAsset(models.Model):
_inherit = 'ir.asset'
key = fields.Char(copy=False, help='Technical field used to resolve multiple assets in a multi-website environment.')
website_id = fields.Many2one('website')
def _get_related_assets(self, domain):
website = self.env['website'].get_current_website(fallback=False)
if website:
domain += website.website_domain()
assets = super()._get_related_assets(domain)
return assets.filter_duplicate()
def _get_active_addons_list(self):
"""Overridden to discard inactive themes."""
addons_list = super()._get_active_addons_list()
website = self.env['website'].get_current_website(fallback=False)
if not website:
return addons_list
IrModule = self.env['ir.module.module'].sudo()
# discard all theme modules except website.theme_id
themes = IrModule.search(IrModule.get_themes_domain()) - website.theme_id
to_remove = set(themes.mapped('name'))
return [name for name in addons_list if name not in to_remove]
def filter_duplicate(self):
""" Filter current recordset only keeping the most suitable asset per distinct name.
Every non-accessible asset will be removed from the set:
* In non website context, every asset with a website will be removed
* In a website context, every asset from another website
"""
current_website = self.env['website'].get_current_website(fallback=False)
if not current_website:
return self.filtered(lambda asset: not asset.website_id)
most_specific_assets = self.env['ir.asset']
for asset in self:
if asset.website_id == current_website:
# specific asset: add it if it's for the current website and ignore
# it if it's for another website
most_specific_assets += asset
elif not asset.website_id:
# no key: added either way
if not asset.key:
most_specific_assets += asset
# generic asset: add it iff for the current website, there is no
# specific asset for this asset (based on the same `key` attribute)
elif not any(asset.key == asset2.key and asset2.website_id == current_website for asset2 in self):
most_specific_assets += asset
return most_specific_assets
+15
View File
@@ -21,6 +21,7 @@ class IrModuleModule(models.Model):
# The order is important because of dependencies (page need view, menu need page)
_theme_model_names = OrderedDict([
('ir.ui.view', 'theme.ir.ui.view'),
('ir.asset', 'theme.ir.asset'),
('website.page', 'theme.website.page'),
('website.menu', 'theme.website.menu'),
('ir.attachment', 'theme.ir.attachment'),
@@ -416,6 +417,20 @@ class IrModuleModule(models.Model):
'res_id': theme.id,
})
def get_themes_domain(self):
"""Returns the 'ir.module.module' search domain matching all available themes."""
def get_id(model_id):
return self.env['ir.model.data'].xmlid_to_res_id(model_id)
return [
('category_id', 'not in', [
get_id('base.module_category_hidden'),
get_id('base.module_category_theme_hidden'),
]),
'|',
('category_id', '=', get_id('base.module_category_theme')),
('category_id.parent_id', '=', get_id('base.module_category_theme'))
]
def _check(self):
super()._check()
View = self.env['ir.ui.view']
+74 -2
View File
@@ -6,9 +6,49 @@ from odoo import api, fields, models
from odoo.tools.translate import xml_translate
from odoo.modules.module import get_resource_from_path
from odoo.addons.base.models.ir_asset import AFTER_DIRECTIVE, APPEND_DIRECTIVE, BEFORE_DIRECTIVE, DEFAULT_SEQUENCE, INCLUDE_DIRECTIVE, PREPEND_DIRECTIVE, REMOVE_DIRECTIVE, REPLACE_DIRECTIVE
_logger = logging.getLogger(__name__)
class ThemeAsset(models.Model):
_name = 'theme.ir.asset'
_description = 'Theme Asset'
key = fields.Char()
name = fields.Char(required=True)
bundle = fields.Char(required=True)
directive = fields.Selection(selection=[
(APPEND_DIRECTIVE, 'Append'),
(PREPEND_DIRECTIVE, 'Prepend'),
(AFTER_DIRECTIVE, 'After'),
(BEFORE_DIRECTIVE, 'Before'),
(REMOVE_DIRECTIVE, 'Remove'),
(REPLACE_DIRECTIVE, 'Replace'),
(INCLUDE_DIRECTIVE, 'Include')], default=APPEND_DIRECTIVE)
glob = fields.Char(required=True)
target = fields.Char()
active = fields.Boolean(default=True)
sequence = fields.Integer(default=DEFAULT_SEQUENCE, required=True)
copy_ids = fields.One2many('ir.asset', 'theme_template_id', 'Assets using a copy of me', copy=False, readonly=True)
def _convert_to_base_model(self, website, **kwargs):
self.ensure_one()
new_asset = {
'name': self.name,
'key': self.key,
'bundle': self.bundle,
'directive': self.directive,
'glob': self.glob,
'target': self.target,
'active': self.active,
'sequence': self.sequence,
'website_id': website.id,
'theme_template_id': self.id,
}
return new_asset
class ThemeView(models.Model):
_name = 'theme.ir.ui.view'
_description = 'Theme UI View'
@@ -23,7 +63,7 @@ class ThemeView(models.Model):
name = fields.Char(required=True)
key = fields.Char()
type = fields.Char()
priority = fields.Integer(default=16, required=True)
priority = fields.Integer(default=DEFAULT_SEQUENCE, required=True)
mode = fields.Selection([('primary', "Base view"), ('extension', "Extension View")])
active = fields.Boolean(default=True)
arch = fields.Text(translate=xml_translate)
@@ -181,7 +221,8 @@ class Theme(models.AbstractModel):
)
# Reinitialize effets
self.disable_view('website.option_ripple_effect')
self.disable_asset('website.ripple_effect_scss')
self.disable_asset('website.ripple_effect_js')
# Reinitialize header templates
self.enable_view('website.template_header_default')
@@ -210,6 +251,24 @@ class Theme(models.AbstractModel):
# Reinitialize footer scrolltop template
self.disable_view('website.option_footer_scrolltop')
@api.model
def _toggle_asset(self, name, active):
ThemeAsset = self.env['theme.ir.asset'].sudo().with_context(active_test=False)
obj = ThemeAsset.search([('name', '=', name)])
website = self.env['website'].get_current_website()
if obj:
obj = obj.copy_ids.filtered(lambda x: x.website_id == website)
else:
Asset = self.env['ir.asset'].sudo().with_context(active_test=False)
obj = Asset.search([('name', '=', name)])
has_specific = obj.key and Asset.search_count([
('key', '=', obj.key),
('website_id', '=', website.id)
]) >= 1
if not has_specific and active == obj.active:
return
obj.write({'active': active})
@api.model
def _toggle_view(self, xml_id, active):
obj = self.env.ref(xml_id)
@@ -232,6 +291,14 @@ class Theme(models.AbstractModel):
return
obj.write({'active': active})
@api.model
def enable_asset(self, name):
self._toggle_asset(name, True)
@api.model
def disable_asset(self, name):
self._toggle_asset(name, False)
@api.model
def enable_view(self, xml_id):
self._toggle_view(xml_id, True)
@@ -271,6 +338,11 @@ class IrUiView(models.Model):
res &= super(IrUiView, no_arch_updated_views).write(vals)
return res
class IrAsset(models.Model):
_inherit = 'ir.asset'
theme_template_id = fields.Many2one('theme.ir.asset', copy=False)
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
@@ -17,6 +17,7 @@ access_website_track_designer,access_website_track_designer,model_website_track,
access_website_track_system,access_website_track_system,model_website_track,base.group_system,1,1,1,1
access_website_route_designer,access_website_designer_route,model_website_route,group_website_designer,1,1,1,1
access_theme_ir_ui_view,access_theme_ir_ui_view,model_theme_ir_ui_view,base.group_system,1,1,1,1
access_theme_ir_asset_group_system,theme_ir_asset_group_system,model_theme_ir_asset,base.group_system,1,1,1,1
access_theme_ir_attachment,access_theme_ir_attachment,model_theme_ir_attachment,base.group_system,1,1,1,1
access_theme_website_menu,access_theme_website_menu,model_theme_website_menu,base.group_system,1,1,1,1
access_theme_website_page,access_theme_website_page,model_theme_website_page,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
17 access_website_track_system access_website_track_system model_website_track base.group_system 1 1 1 1
18 access_website_route_designer access_website_designer_route model_website_route group_website_designer 1 1 1 1
19 access_theme_ir_ui_view access_theme_ir_ui_view model_theme_ir_ui_view base.group_system 1 1 1 1
20 access_theme_ir_asset_group_system theme_ir_asset_group_system model_theme_ir_asset base.group_system 1 1 1 1
21 access_theme_ir_attachment access_theme_ir_attachment model_theme_ir_attachment base.group_system 1 1 1 1
22 access_theme_website_menu access_theme_website_menu model_theme_website_menu base.group_system 1 1 1 1
23 access_theme_website_page access_theme_website_page model_theme_website_page base.group_system 1 1 1 1
+7 -8
View File
@@ -34,8 +34,8 @@ class TestQweb(TransactionCaseWithUserDemo):
demo_env = self.env(user=demo)
html = demo_env['ir.qweb']._render('website.test_template', {"user": demo}, website_id= website.id)
asset_data = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[0]
asset_xmlid = asset_data.attrib.get('data-asset-xmlid')
asset_data = etree.HTML(html).xpath('//*[@data-asset-bundle]')[0]
asset_xmlid = asset_data.attrib.get('data-asset-bundle')
asset_version = asset_data.attrib.get('data-asset-version')
html = html.strip().decode('utf8')
@@ -53,17 +53,16 @@ class TestQweb(TransactionCaseWithUserDemo):
"asset_xmlid": asset_xmlid,
"asset_version": asset_version,
}
self.assertEqual(html, ("""<!DOCTYPE html>
self.assertHTMLEqual(html, ("""<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="http://test.external.link/style1.css"/>
<link rel="stylesheet" href="http://test.external.link/style2.css"/>
<link type="text/css" rel="stylesheet" href="http://test.cdn%(css)s" data-asset-xmlid="%(asset_xmlid)s" data-asset-version="%(asset_version)s"/>
<link type="text/css" rel="stylesheet" href="http://test.external.link/style1.css"/>
<link type="text/css" rel="stylesheet" href="http://test.external.link/style2.css"/>
<link type="text/css" rel="stylesheet" href="http://test.cdn%(css)s" data-asset-bundle="%(asset_xmlid)s" data-asset-version="%(asset_version)s"/>
<meta/>
<script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
<script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
<script type="text/javascript" src="http://test.cdn%(js)s" data-asset-xmlid="%(asset_xmlid)s" data-asset-version="%(asset_version)s"></script>
<script type="text/javascript" src="http://test.cdn%(js)s" data-asset-bundle="%(asset_xmlid)s" data-asset-version="%(asset_version)s"></script>
</head>
<body>
<img src="http://test.external.link/img.png" loading="lazy"/>
+18 -11
View File
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import odoo
import odoo.tests
@@ -142,18 +144,23 @@ class TestUi(odoo.tests.HttpCase):
def test_05_specific_website_editor(self):
website_default = self.env['website'].search([], limit=1)
new_website = self.env['website'].create({'name': 'New Website'})
website_editor_assets_view = self.env.ref('website.assets_wysiwyg')
self.env['ir.ui.view'].create({
'name': 'Editor Extension',
'type': 'qweb',
'inherit_id': website_editor_assets_view.id,
'website_id': new_website.id,
'arch': """
<xpath expr="." position="inside">
<script type="text/javascript">document.body.dataset.hello = 'world';</script>
</xpath>
""",
code = b"document.body.dataset.hello = 'world';"
attach = self.env['ir.attachment'].create({
'name': 'EditorExtension.js',
'mimetype': 'text/javascript',
'datas': base64.b64encode(code),
})
custom_url = '/web/content/%s/%s' % (attach.id, attach.name)
attach.url = custom_url
self.env['ir.asset'].create({
'name': 'EditorExtension',
'bundle': 'website.assets_wysiwyg',
'glob': custom_url,
'website_id': new_website.id,
})
self.start_tour("/?fw=%s" % website_default.id, "generic_website_editor", login='admin')
self.start_tour("/?fw=%s" % new_website.id, "specific_website_editor", login='admin')
+2 -2
View File
@@ -862,7 +862,7 @@ class TestCowViewSaving(TestViewSavingCommon):
'name': 'Main layout',
'mode': 'extension',
'inherit_id': base_view.id,
'arch': '<xpath expr="//t[@t-set=\'head_website\']" position="replace"><t t-call-assets="web_editor.assets_summernote" t-js="false" groups="website.group_website_publisher"/></xpath>',
'arch': '<xpath expr="//t[@t-set=\'head_website\']" position="replace"><t t-call-assets="assets_summernote" t-js="false" groups="website.group_website_publisher"/></xpath>',
'key': '_website.layout',
})
@@ -875,7 +875,7 @@ class TestCowViewSaving(TestViewSavingCommon):
'name': 'Forum Layout',
'mode': 'primary',
'inherit_id': inherit_view.id,
'arch': '<xpath expr="//t[@t-call-assets=\'web_editor.assets_summernote\'][@t-js=\'false\']" position="attributes"><attribute name="groups"/></xpath>',
'arch': '<xpath expr="//t[@t-call-assets=\'assets_summernote\'][@t-js=\'false\']" position="attributes"><attribute name="groups"/></xpath>',
'key': '_website_forum.layout',
})])
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="asset_view_form_inherit_website" model="ir.ui.view">
<field name="name">ir.asset.form.inherit.website</field>
<field name="model">ir.asset</field>
<field name="inherit_id" ref="base.asset_view_form"/>
<field name="arch" type="xml">
<field name="directive" position="after">
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
</field>
</field>
</record>
</odoo>
+1 -4
View File
@@ -516,10 +516,7 @@
(0, 0, {'view_mode': 'kanban', 'view_id': ref('website.theme_view_kanban')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('website.theme_view_form_preview')})]"/>
<field name="search_view_id" ref="theme_view_search"/>
<field name="domain" eval="[
('category_id', 'not in', [ref('base.module_category_hidden', False), ref('base.module_category_theme_hidden', False)]),
'|', ('category_id', '=', ref('base.module_category_theme', False)), ('category_id.parent_id', '=', ref('base.module_category_theme', False))
]"/>
<field name="domain" model="ir.module.module" eval="obj().get_themes_domain()"/>
</record>
<record id="theme_install_act_url" model="ir.actions.act_url">
+221 -50
View File
@@ -81,24 +81,36 @@ Assets Management
Managing assets in Odoo is not as straightforward as it is in some other apps.
One of the reason is that we have a variety of situations where some, but not all
the assets are required. For example, the needs of the web client, the point of
sale, the website or even the mobile application are different. Also, some
assets may be large, but are seldom needed. In that case, we sometimes want them
the assets are required. For example, the needs of the web client, the point of
sale, the website or even the mobile application are different. Also, some
assets may be large, but are seldom needed. In that case, we sometimes want them
to be loaded lazily.
The main idea is that we define a set of *bundles* in xml. A bundle is here defined as
a collection of files (javascript, css, scss). In Odoo, the most important
bundles are defined in the file *addons/web/views/webclient_templates.xml*. It looks
like this:
The main idea is that we define a set of *bundles* in the module manifest. A
bundle is here defined as a list of files (xml, javascript, css, scss). Files
are declared using `glob`_ syntax, meaning that you can declare several asset
files using a single line. Each file found using a glob will be appended to the
`<head>` of the page, at most once, in the order the globs are given.
.. code-block:: xml
As mentionned, the bundles are declared in each module's `__manifest__.py`, under
a dedicated `assets` key which contains a dictionary. Said dictionary will declare
bundles (keys) with the files they contain (values). It looks like this:
<template id="web.assets_common" name="Common Assets (used in backend interface and website)">
<link rel="stylesheet" type="text/css" href="/web/static/lib/jquery.ui/jquery-ui.css"/>
...
<script type="text/javascript" src="/web/static/src/js/boot.js"></script>
...
</template>
.. code-block:: py
'assets': {
'assets_backend': [
'web/static/src/xml/**/*',
],
'assets_common': [
'web/static/lib/bootstrap/**/*',
'web/static/src/js/boot.js',
'web/static/src/js/webclient.js',
],
'qunit_suite_tests': [
'web/static/src/js/webclient_tests.js',
],
},
The files in a bundle can then be inserted into a template by using the *t-call-assets*
@@ -106,8 +118,8 @@ directive:
.. code-block:: xml
<t t-call-assets="web.assets_common" t-js="false"/>
<t t-call-assets="web.assets_common" t-css="false"/>
<t t-call-assets="assets_common" t-js="false"/>
<t t-call-assets="assets_common" t-css="false"/>
Here is what happens when a template is rendered by the server with these directives:
@@ -139,52 +151,208 @@ and if necessary, will create/recreate the corresponding bundles.
Here are some important bundles that most developers will need to know:
- *web.assets_common*: this bundle contains most assets which are common to the
- *assets_common*: this bundle contains most assets which are common to the
web client, the website, and also the point of sale. This is supposed to contain
lower level building blocks for the odoo framework. Note that it contains the
lower level building blocks for the odoo framework. Note that it contains the
*boot.js* file, which defines the odoo module system.
- *web.assets_backend*: this bundle contains the code specific to the web client
(notably the web client/action manager/views)
- *assets_backend*: this bundle contains the code specific to the web client
(notably the web client/action manager/views) and all static XML templates used
in the backend environment
- *web.assets_frontend*: this bundle is about all that is specific to the public
- *assets_frontend*: this bundle is about all that is specific to the public
website: ecommerce, forum, blog, event management, ...
Operations on asset bundles
---------------------------
Adding files in an asset bundle
-------------------------------
Typically, handling assets is quite trivial: you just need to add some new files
to a frequently used bundle like 'common' or 'backend'. But there are other operations
available to cover use cases bringing additional constraints. Such cases can mostly
be covered with the following operations.
The proper way to add a file located in *addons/web* to a bundle is simple:
it is just enough to add a *script* or a *stylesheet* tag to the bundle in the
file *webclient_templates.xml*. But when we work in a different addon, we need
to add a file from that addon. In that case, it should be done in three steps:
a) Add one or multiple file(s): append
1. add a *assets.xml* file in the *views/* folder
2. add the string 'views/assets.xml' in the 'data' key in the manifest file
3. create an inherited view of the desired bundle, and add the file(s) with an
xpath expression. For example,
The proper way to add a file to a bundle in any addon is simple: it is just enough
to add a glob path to the bundle in the file *__manifest__.py* like so:
.. code-block:: xml
.. code-block:: py
<template id="assets_backend" name="helpdesk assets" inherit_id="web.assets_backend">
<xpath expr="//script[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/helpdesk/static/src/scss/helpdesk.scss"/>
<script type="text/javascript" src="/helpdesk/static/src/js/helpdesk_dashboard.js"></script>
</xpath>
</template>
'assets_common': [
'my_addon/static/src/js/**/*',
],
By default, adding a simple string to a bundle will append the files matching the
glob at the end of the bundle.
b) Add one or multiple file(s) at the beginning of the list: prepend
Sometimes you need to put a certain file before the others in a bundle, when
loading css file for example. In this case, you can use the *prepend* directive
like so:
.. code-block:: py
'assets_common': [
('prepend', 'my_addon/static/src/css/bootstrap_overridden.scss'),
],
c) Add one or multiple file(s) before a specific file: before
Prepending a file at the beginning of a bundle might not be precise enough. The
*before* directive can be used to add the given files right before the target
file. The syntax is `('before', target, glob)`:
.. code-block:: py
'assets_common': [
('before', 'web/static/src/css/bootstrap_overridden.scss', 'my_addon/static/src/css/bootstrap_overridden.scss'),
],
d) Add one or multiple file(s) after a specofic file: after
Same as *before*, with the resulting files appended after the target file. The
syntax is `('after', target, glob)`:
.. code-block:: py
'assets_common': [
('after', 'web/static/src/css/list_view.scss', 'my_addon/static/src/css/list_view.scss'),
],
e) Use nested bundles: include
The *include* directive is a way to use a same bundle in other bundles to minimize
the size of your manifest. In Odoo we use sub bundles (prefixed with an underscore
by convention) to batch glob files used in multiple other bundles. You can then
specify the sub bundle like this:
.. code-block:: py
'assets_common': [
('include', 'web._primary_variables'),
],
f) Remove one or multiple file(s): remove
In some additional module you may want to get rid of the call of a certain asset
in a bundle. Any file can be removed from an existing bundle using the *remove*
directive:
.. code-block:: py
'assets_common': [
('remove', 'web/static/src/js/boot.js'),
],
g) Replace an asset file with one or multiple file(s): replace
Let us now say that an asset need not only to be removed, but you also want to insert
your new version of that asset at the same exact position. This can be done with
the *replace* directive, using a 3-element tuple `('replace', target, asset)`:
.. code-block:: py
'assets_common': [
('replace', 'web/static/src/js/boot.js', 'my_addon/static/src/js/boot.js'),
],
Note that directives targetting a certain file (i.e. *before*, *after*, *replace* and *remove*)
need that file to be declared beforehand, either in manifests higher up in the hierarchy
or in 'ir.asset' records with a lower sequence.
.. note ::
Note that the files in a bundle are all loaded immediately when the user loads the
odoo web client. This means that the files are transferred through the network
every time (except when the browser cache is active). In some cases, it may be
better to lazyload some assets. For example, if a widget requires a large
odoo web client. This means that the files are transferred through the network
every time (except when the browser cache is active). In some cases, it may be
better to lazyload some assets. For example, if a widget requires a large
library, and that widget is not a core part of the experience, then it may be
a good idea to only load the library when the widget is actually created. The
widget class has actually builtin support just for this use case. (see section
:ref:`reference/javascript_reference/qweb`)
Assets loading order
-------------------
The order in which assets are loaded is sometimes critical and must be deterministic,
mostly for stylesheets priorities and setup scripts. Assets in Odoo are processed
as follows.
1. When an asset bundle is called (e.g. `t-call-assets="assets_common"`), an empty
list of assets is generated
2. All records of type 'ir.asset' matching the bundle will be fetched and sorted
by sequence number. Then all records with a sequence strictly less than 16 will
be processed and applied to the current list of assets.
3. All modules declaring assets for said bundle in their manifest will apply their
assets operations to this list. This is done following the order of modules dependencies
(e.g. 'web' assets will be processed before 'website'). If a directive tries to add
a file already present in the list, nothing is done for that file. In other word,
only the first occurrence of a file is kept in the list.
4. The remaining 'ir.asset' records (those with a sequence greater than or equal
to 16) are eventually processed and applied as well.
Assets declared in the manifest may need to be loaded in a particular order, for
example `jquery.js` must be loaded before all other jquery scripts when loading the
lib folder. One solution would be to create an 'ir.asset' record with a lower sequence
or a 'prepend' directive, but there is another simpler way to do so.
Since the unicity of each file path in the list of assets is guaranteed, you can
mention any specific file before a glob that includes it. That file will thus appear
in the list before all the others included in the glob.
.. code-block:: py
'assets_common': [
'my_addon/static/lib/jquery/jquery.js',
'my_addon/static/lib/jquery/**/*',
],
.. note ::
A module *b* removing/replacing the assets declared in a module *a* will have
to depend on it. Trying to operate on assets that have yet to be declared will
result in an error.
The 'ir.asset' model
--------------------
In most cases the assets declared in the manifest will largely suffice. But Odoo
being highly customizable requires to modify things as critical as defining assets
to be editted in place. A model 'ir.asset' exists to do such things. Records will
be associated to a `bundle` and apply their `glob` (and `target` if any) to the
list of assets using according to their `directive`. Each record of 'ir.asset' has
the following fields:
.. autoclass:: odoo.addons.base.models.ir_asset.IrAsset
``name``
Name of the asset record (for identification purpose).
``bundle``
Bundle in which the asset will be applied.
``directive`` (default='append')
Directive to use on the bundle.
``glob``
Glob string defining:
a) a path to a file/folder in the Odoo file system
b) a URL to an attachment
``target``
Target file to specify a position in the bundle. Can only be used with the
directives 'replace', 'before' and 'after'.
``active`` (default=True)
``sequence`` (default=16)
Loading order of the asset records (ascending). A sequence lower than 16 means
that the asset will be processed __before__ the ones declared in the manifest.
What to do if a file is not loaded/updated
------------------------------------------
@@ -225,7 +393,7 @@ another file. There are two ways of sharing code between files:
- use a module system that will provide a way for each modules to export or import
values, and will make sure that they are loaded in a proper order.
While it's possible to work in the global scope, this has a number of issues:
It is difficult to ensure that implementation details are not exposed by work done in the global scope directly.
@@ -241,14 +409,14 @@ It is difficult to ensure that implementation details are not exposed by work do
For most Odoo code, we want to use a module system. Because of the way assets
work in Odoo (and in particular, the fact that each installed odoo addon can
modify the list of files contained in a bundle), Odoo has to resolve modules
modify the list of files contained in a bundle), Odoo has to resolve modules
browser side. To do that, Odoo provides a small module system described just
below (see :ref:`reference/javascript_reference/odoo_module`).
However, Odoo also provides support for native javascript modules (see
However, Odoo also provides support for native javascript modules (see
:ref:`reference/javascript_reference/js_module`). These modules
will simply be translated by the server into odoo modules. It is encouraged to
write all javascript code as a native module, for a better IDE integration. In
write all javascript code as a native module, for a better IDE integration. In
the future, the Odoo module system should be considered an implementation detail,
not the primary way to write javascript code.
@@ -442,9 +610,9 @@ error). This file will then be translated into an Odoo module that look like thi
.. code-block:: javascript
odoo.define('@web/file_a', function (require) {
'use strict';
let __exports = {};
odoo.define('@web/file_a', function (require) {
'use strict';
let __exports = {};
const { someFunction } = require("@web/file_b");
@@ -486,14 +654,14 @@ The file ``file_b`` can import ``file_a`` like this:
.. code-block:: javascript
/** @odoo-module **/
import {something} from `./file_a`
import {something} from `./file_a`
But ``file_c`` need to use the full name:
.. code-block:: javascript
/** @odoo-module **/
import {something} from `@web/file_a`
import {something} from `@web/file_a`
Aliased modules
@@ -2669,6 +2837,9 @@ do that, several steps should be done.
The ``updateControlPanel`` is the main method to customize the content in controlpanel.
For more information, look into the `control_panel_renderer.js <https://github.com/odoo/odoo/blob/13.0/addons/web/static/src/js/views/control_panel/control_panel_renderer.js#L130>`_ file.
.. _glob:
https://en.wikipedia.org/wiki/Glob_(programming)
.. _.appendTo():
https://api.jquery.com/appendTo/
+1
View File
@@ -28,6 +28,7 @@ The kernel of Odoo, needed for all installation.
'views/res_config_views.xml',
'data/res.country.state.csv',
'views/ir_actions_views.xml',
'views/ir_asset_views.xml',
'views/ir_config_parameter_views.xml',
'views/ir_cron_views.xml',
'views/ir_cron_trigger_views.xml',
+1
View File
@@ -8,6 +8,7 @@ from . import ir_model
from . import ir_sequence
from . import ir_ui_menu
from . import ir_ui_view
from . import ir_asset
from . import ir_actions
from . import ir_actions_report
from . import ir_attachment
+8 -7
View File
@@ -122,6 +122,7 @@ class AssetsBundle(object):
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:
if f['atype'] == 'text/sass':
@@ -154,7 +155,7 @@ class AssetsBundle(object):
["type", "text/css"],
["rel", "stylesheet"],
["href", href],
['data-asset-xmlid', self.name],
['data-asset-bundle', self.name],
['data-asset-version', self.version],
])
response.append(("link", attr, None))
@@ -171,7 +172,7 @@ class AssetsBundle(object):
["defer", "defer" if defer_load or lazy_load else None],
["type", "text/javascript"],
["data-src" if lazy_load else "src", src],
['data-asset-xmlid', self.name],
['data-asset-bundle', self.name],
['data-asset-version', self.version],
])
response.append(("script", attr, None))
@@ -343,7 +344,7 @@ class AssetsBundle(object):
channel = (self.env.registry.db_name, 'bundle_changed')
message = (self.name, self.version)
self.env['bus.bus'].sendone(channel, message)
_logger.debug('Asset Changed: xml_id: %s -- version: %s' % message)
_logger.debug('Asset Changed: bundle: %s -- version: %s', message, message)
return attachment
@@ -822,14 +823,14 @@ class JavascriptAsset(WebAsset):
return ("script", OrderedDict([
["type", "text/javascript"],
["src", self.html_url],
['data-asset-xmlid', self.bundle.name],
['data-asset-bundle', self.bundle.name],
['data-asset-version', self.bundle.version],
]), None)
else:
return ("script", OrderedDict([
["type", "text/javascript"],
["charset", "utf-8"],
['data-asset-xmlid', self.bundle.name],
['data-asset-bundle', self.bundle.name],
['data-asset-version', self.bundle.version],
]), self.with_header())
@@ -927,7 +928,7 @@ class StylesheetAsset(WebAsset):
["rel", "stylesheet"],
["href", self.html_url],
["media", escape(to_text(self.media)) if self.media else None],
['data-asset-xmlid', self.bundle.name],
['data-asset-bundle', self.bundle.name],
['data-asset-version', self.bundle.version],
])
return ("link", attr, None)
@@ -935,7 +936,7 @@ class StylesheetAsset(WebAsset):
attr = OrderedDict([
["type", "text/css"],
["media", escape(to_text(self.media)) if self.media else None],
['data-asset-xmlid', self.bundle.name],
['data-asset-bundle', self.bundle.name],
['data-asset-version', self.bundle.version],
])
return ("style", attr, self.with_header())
+425
View File
@@ -0,0 +1,425 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import os
from glob import glob
from logging import getLogger
from werkzeug import urls
import odoo
from odoo.tools import misc
from odoo import tools
from odoo.addons import __path__ as ADDONS_PATH
from odoo import api, fields, http, models
_logger = getLogger(__name__)
SCRIPT_EXTENSIONS = ('js',)
STYLE_EXTENSIONS = ('css', 'scss', 'sass', 'less')
TEMPLATE_EXTENSIONS = ('xml',)
DEFAULT_SEQUENCE = 16
# Directives are stored in variables for ease of use and syntax checks.
APPEND_DIRECTIVE = 'append'
PREPEND_DIRECTIVE = 'prepend'
AFTER_DIRECTIVE = 'after'
BEFORE_DIRECTIVE = 'before'
REMOVE_DIRECTIVE = 'remove'
REPLACE_DIRECTIVE = 'replace'
INCLUDE_DIRECTIVE = 'include'
# Those are the directives used with a 'target' argument/field.
DIRECTIVES_WITH_TARGET = [AFTER_DIRECTIVE, BEFORE_DIRECTIVE, REPLACE_DIRECTIVE]
WILDCARD_CHARACTERS = {'*', "?", "[", "]"}
def fs2web(path):
"""Converts a file system path to a web path"""
return '/'.join(os.path.split(path))
def can_aggregate(url):
parsed = urls.url_parse(url)
return not parsed.scheme and not parsed.netloc and not url.startswith('/web/content')
def is_wildcard_glob(path):
"""Determine whether a path is a wildcarded glob eg: "/web/file[14].*"
or a genuine single file path "/web/myfile.scss"""
return not WILDCARD_CHARACTERS.isdisjoint(path)
class IrAsset(models.Model):
"""This model contributes to two things:
1. It provides a function returning a list of all file paths declared
in a given list of addons (see _get_addon_paths);
2. It allows to create 'ir.asset' records to add additional directives
to certain bundles.
"""
_name = 'ir.asset'
_description = 'Asset'
_order = 'sequence, id'
@api.model_create_multi
def create(self, vals_list):
self.clear_caches()
return super().create(vals_list)
def write(self, values):
self.clear_caches()
return super().write(values)
def unlink(self):
self.clear_caches()
return super().unlink()
name = fields.Char(string='Name', required=True)
bundle = fields.Char(string='Bundle name', required=True)
directive = fields.Selection(string='Directive', selection=[
(APPEND_DIRECTIVE, 'Append'),
(PREPEND_DIRECTIVE, 'Prepend'),
(AFTER_DIRECTIVE, 'After'),
(BEFORE_DIRECTIVE, 'Before'),
(REMOVE_DIRECTIVE, 'Remove'),
(REPLACE_DIRECTIVE, 'Replace'),
(INCLUDE_DIRECTIVE, 'Include')], default=APPEND_DIRECTIVE)
glob = fields.Char(string='Path', required=True)
target = fields.Char(string='Target')
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):
"""
Fetches all asset file paths from a given list of addons matching a
certain bundle. The returned list is composed of tuples containing the
file path [1], the first addon calling it [0] and the bundle name.
Asset loading is performed as follows:
1. All 'ir.asset' records matching the given bundle and with a sequence
strictly less than 16 are applied.
3. The manifests of the given addons are checked for assets declaration
for the given bundle. If any, they are read sequentially and their
operations are applied to the current list.
4. After all manifests have been parsed, the remaining 'ir.asset'
records matching the bundle are also applied to the current list.
:param bundle: name of the bundle from which to fetch the file paths
: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
:returns: the list of tuples (path, addon, bundle)
"""
installed = self._get_installed_addons_list()
if addons is None:
addons = self._get_active_addons_list()
asset_paths = AssetPaths()
self._fill_asset_paths(bundle, addons, installed, css, js, xml, asset_paths, [])
return asset_paths.list
def _fill_asset_paths(self, bundle, addons, installed, css, js, xml, asset_paths, seen):
"""
Fills the given AssetPaths instance by applying the operations found in
the matching bundle of the given addons manifests.
See `_get_asset_paths` for more information.
:param bundle: name of the bundle from which to fetch the file paths
:param addons: list of addon names as strings
: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 asset_paths: the AssetPath object to fill
:param seen: a list of bundles already checked to avoid circularity
"""
if bundle in seen:
raise Exception("Circular assets bundle declaration: %s" % " > ".join(seen + [bundle]))
manifest_cache = http.addons_manifest
exts = []
if js:
exts += SCRIPT_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.
bundle_start_index = len(asset_paths.list)
def process_path(directive, target, path_def):
"""
This sub function is meant to take a directive and a set of
arguments and apply them to the current asset_paths list
accordingly.
It is nested inside `_get_asset_paths` since we need the current
list of addons, extensions, asset_paths and manifest_cache.
:param directive: string
:param target: string or None or False
:param path_def: string
"""
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])
return
addon, paths = self._get_paths(path_def, installed, exts)
# retrieve target index when it applies
if directive in DIRECTIVES_WITH_TARGET:
_, target_paths = self._get_paths(target, installed, exts)
if not target_paths and target.rpartition('.')[2] not in exts:
# nothing to do: the extension of the target is wrong
return
target_to_index = len(target_paths) and target_paths[0] or target
target_index = asset_paths.index(target_to_index, addon, bundle)
if directive == APPEND_DIRECTIVE:
asset_paths.append(paths, addon, bundle)
elif directive == PREPEND_DIRECTIVE:
asset_paths.insert(paths, addon, bundle, bundle_start_index)
elif directive == AFTER_DIRECTIVE:
asset_paths.insert(paths, addon, bundle, target_index + 1)
elif directive == BEFORE_DIRECTIVE:
asset_paths.insert(paths, addon, bundle, target_index)
elif directive == REMOVE_DIRECTIVE:
asset_paths.remove(paths, addon, bundle, path_def)
elif directive == REPLACE_DIRECTIVE:
asset_paths.insert(paths, addon, bundle, target_index)
asset_paths.remove(target_paths, addon, bundle)
else:
# this should never happen
raise ValueError("Unexpected directive")
# 1. Process the first sequence of 'ir.asset' records
assets = self._get_related_assets([('bundle', '=', bundle)]).filtered('active')
for asset in assets.filtered(lambda a: a.sequence < DEFAULT_SEQUENCE):
process_path(asset.directive, asset.target, asset.glob)
# 2. Process all addons' manifests.
for addon in self._topological_sort(tuple(addons)):
manifest = manifest_cache.get(addon)
if not manifest:
continue
manifest_assets = manifest.get('assets', {})
for command in manifest_assets.get(bundle, []):
if isinstance(command, str):
# Default directive: append
directive, target, path_def = APPEND_DIRECTIVE, None, command
elif command[0] in DIRECTIVES_WITH_TARGET:
directive, target, path_def = command
else:
directive, path_def = command
target = None
process_path(directive, target, path_def)
# 3. Process the rest of 'ir.asset' records
for asset in assets.filtered(lambda a: a.sequence >= DEFAULT_SEQUENCE):
process_path(asset.directive, asset.target, asset.glob)
def _get_related_assets(self, domain):
"""
Returns a set of assets matching the domain, regardless of their
active state. This method can be overridden to filter the results.
:param domain: search domain
:returns: ir.asset recordset
"""
return self.with_context(active_test=False).sudo().search(domain, order='sequence, id')
def _get_related_bundle(self, target_path_def, root_bundle):
"""
Returns the first bundle directly defining a glob matching the target
path. This is useful when generating an 'ir.asset' record to override
a specific asset and target the right bundle, i.e. the first one
defining the target path.
:param target_path_def: string: path to match.
:root_bundle: string: bundle from which to initiate the search.
:returns: the first matching bundle or None
"""
ext = target_path_def.split('.')[-1]
installed = self._get_installed_addons_list()
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
asset_paths = self._get_asset_paths(root_bundle, css=css, js=js, xml=xml)
for path, _, bundle in asset_paths:
if path == target_path:
return bundle
return root_bundle
def _get_active_addons_list(self):
"""Can be overridden to filter the returned list of active modules."""
return self._get_installed_addons_list()
@api.model
@tools.ormcache('addons_tuple')
def _topological_sort(self, addons_tuple):
"""Returns a list of sorted modules name accord to the spec in ir.module.module
that is, application desc, sequence, name then topologically sorted"""
IrModule = self.env['ir.module.module']
def mapper(addon):
manif = http.addons_manifest.get(addon, {})
from_terp = IrModule.get_values_from_terp(manif)
from_terp['name'] = addon
from_terp['depends'] = manif.get('depends', ['base'])
return from_terp
manifs = map(mapper, addons_tuple)
def sort_key(manif):
return (not manif['application'], int(manif['sequence']), manif['name'])
manifs = sorted(manifs, key=sort_key)
return misc.topological_sort({manif['name']: manif['depends'] for manif in manifs})
@api.model
@tools.ormcache_context(keys='install_module')
def _get_installed_addons_list(self):
"""
Returns the list of all installed addons.
:returns: string[]: list of module names
"""
# Main source: the current registry list
# Second source of modules: server wide modules
# Third source: the currently loading module from the context (similar to ir_ui_view)
return self.env.registry._init_modules | set(odoo.conf.server_wide_modules or []) | set(self.env.context.get('install_module', []))
def _get_paths(self, path_def, installed, extensions=None):
"""
Returns a list of file paths matching a given glob (path_def) as well as
the addon targetted by the path definition. If no file matches that glob,
the path definition is returned as is. This is either because the glob is
not correctly written or because it points to an URL.
:param path_def: the definition (glob) of file paths to match
:param installed: the list of installed addons
:param extensions: a list of extensions that found files must match
:returns: a tuple: the addon targetted by the path definition [0] and the
list of glob files matching the definition [1] (or the glob itself if
none). Note that these paths are filtered on the given `extensions`.
"""
paths = []
path_url = fs2web(path_def)
path_parts = [part for part in path_url.split('/') if part]
addon = path_parts[0]
addon_manifest = http.addons_manifest.get(addon)
safe_path = True
if addon_manifest:
if addon not in installed:
# Assert that the path is in the installed addons
raise Exception("Unallowed to fetch files from addon %s" % addon)
addons_path = os.path.join(addon_manifest['addons_path'], '')[:-1]
full_path = os.path.normpath(os.path.join(addons_path, *path_parts))
# first security layer: forbid escape from the current addon
# "/mymodule/../myothermodule" is forbidden
# the condition after the or is to further guarantee that we won't access
# a directory that happens to be named like an addon (web....)
if addon not in full_path or addons_path not in full_path:
addon = None
safe_path = False
else:
paths = [
path for path in sorted(glob(full_path, recursive=True))
]
# second security layer: do we have the right to access the files
# that are grabbed by the glob ?
# In particular we don't want to expose data in xmls of the module
def is_safe_path(path):
try:
misc.file_path(path, SCRIPT_EXTENSIONS + STYLE_EXTENSIONS + TEMPLATE_EXTENSIONS)
except (ValueError, FileNotFoundError):
return False
if path.rpartition('.')[2] in TEMPLATE_EXTENSIONS:
# Forbid xml to leak
return ("%s/static/" % addon) in path
return True
len_paths = len(paths)
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 path[len(addons_path):] for path in paths]
else:
addon = None
if not paths and (not can_aggregate(path_url) or (safe_path and not is_wildcard_glob(path_url))):
# No file matching the path; the path_def could be a url.
paths = [path_url]
if not paths:
msg = f'IrAsset: the path "{path_def}" did not resolve to anything.'
if not safe_path:
msg += " It may be due to security reasons."
_logger.warning(msg)
# Paths are filtered on the extensions (if any).
return addon, [
path
for path in paths
if not extensions or path.split('.')[-1] in extensions
]
class AssetPaths:
""" A list of asset paths (path, addon, bundle) with efficient operations. """
def __init__(self):
self.list = []
self.memo = set()
def index(self, path, addon, bundle):
"""Returns the index of the given path in the current assets list."""
if path not in self.memo:
self._raise_not_found(path, bundle)
for index, asset in enumerate(self.list):
if asset[0] == path:
return index
def append(self, paths, addon, bundle):
"""Appends the given paths to the current list."""
for path in paths:
if path not in self.memo:
self.list.append((path, addon, bundle))
self.memo.add(path)
def insert(self, paths, addon, bundle, index):
"""Inserts the given paths to the current list at the given position."""
to_insert = []
for path in paths:
if path not in self.memo:
to_insert.append((path, addon, bundle))
self.memo.add(path)
self.list[index:index] = to_insert
def remove(self, paths, addon, bundle, glob=None):
"""Removes the given paths from the current list."""
paths = {path for path in paths if path in self.memo}
if paths:
self.list[:] = [asset for asset in self.list if asset[0] not in paths]
self.memo.difference_update(paths)
return
if glob:
self._raise_not_found(glob, bundle)
def _raise_not_found(self, path, bundle):
raise ValueError("File %s not found in bundle %s" % (path, bundle))
+1
View File
@@ -148,6 +148,7 @@ STATES = [
('to install', 'To be installed'),
]
class Module(models.Model):
_name = "ir.module.module"
_rec_name = "shortdesc"
+56 -59
View File
@@ -9,7 +9,6 @@ from time import time
from lxml import html
from lxml import etree
from werkzeug import urls
from odoo import api, models, tools
from odoo.tools.safe_eval import assert_valid_codeobj, _BUILTINS, _SAFE_OPCODES
@@ -19,6 +18,7 @@ from odoo.modules.module import get_resource_path
from odoo.addons.base.models.qweb import QWeb, Contextifier
from odoo.addons.base.models.assetsbundle import AssetsBundle
from odoo.addons.base.models.ir_asset import can_aggregate, STYLE_EXTENSIONS, SCRIPT_EXTENSIONS
_logger = logging.getLogger(__name__)
@@ -159,7 +159,7 @@ class IrQWeb(models.AbstractModel, QWeb):
if len(el):
raise SyntaxError("t-call-assets cannot contain children nodes")
# nodes = self._get_asset_nodes(xmlid, options, css=css, js=js, debug=values.get('debug'), async=async, values=values)
# nodes = self._get_asset_nodes(bundle, options, css=css, js=js, debug=values.get('debug'), async=async, values=values)
#
# for index, (tagName, t_attrs, content) in enumerate(nodes):
# if index:
@@ -216,7 +216,7 @@ class IrQWeb(models.AbstractModel, QWeb):
ast.keyword('async_load', self._get_attr_bool(el.get('async_load', False))),
ast.keyword('defer_load', self._get_attr_bool(el.get('defer_load', False))),
ast.keyword('lazy_load', self._get_attr_bool(el.get('lazy_load', False))),
ast.keyword('values', ast.Name(id='values', ctx=ast.Load())),
ast.keyword('media', ast.Constant(el.get('media'))),
],
starargs=None, kwargs=None
)
@@ -281,92 +281,89 @@ class IrQWeb(models.AbstractModel, QWeb):
# method called by computing code
def get_asset_bundle(self, xmlid, files, env=None, css=True, js=True):
return AssetsBundle(xmlid, files, env=env, css=css, js=js)
def get_asset_bundle(self, bundle_name, files, env=None, css=True, js=True):
return AssetsBundle(bundle_name, files, env=env, css=css, js=js)
def _get_asset_nodes(self, xmlid, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, values=None):
def _get_asset_nodes(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
"""Generates asset nodes.
If debug=assets, the assets will be regenerated when a file which composes them has been modified.
Else, the assets will be generated only once and then stored in cache.
"""
if debug and 'assets' in debug:
return self._generate_asset_nodes(xmlid, options, css, js, debug, async_load, defer_load, lazy_load, values)
return self._generate_asset_nodes(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
else:
return self._generate_asset_nodes_cache(xmlid, options, css, js, debug, async_load, defer_load, lazy_load, values)
return self._generate_asset_nodes_cache(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
@tools.conditional(
# 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('xmlid', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', keys=("website_id",)),
tools.ormcache_context('bundle', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', keys=("website_id",)),
)
def _generate_asset_nodes_cache(self, xmlid, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, values=None):
return self._generate_asset_nodes(xmlid, options, css, js, debug, async_load, defer_load, lazy_load, values)
def _generate_asset_nodes_cache(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
return self._generate_asset_nodes(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
def _generate_asset_nodes(self, xmlid, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, values=None):
files, remains = self._get_asset_content(xmlid, options)
asset = self.get_asset_bundle(xmlid, 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] != 'link')]
def _generate_asset_nodes(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
nodeAttrs = None
if css and media:
nodeAttrs = {
'media': media,
}
files, remains = self._get_asset_content(bundle, options, nodeAttrs)
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)
def _get_asset_link_urls(self, xmlid, options):
asset_nodes = self._get_asset_nodes(xmlid, options, js=False)
def _get_asset_link_urls(self, bundle, options):
asset_nodes = self._get_asset_nodes(bundle, options, js=False)
return [node[1]['href'] for node in asset_nodes if node[0] == 'link']
@tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', keys=("website_id",))
def _get_asset_content(self, xmlid, options):
@tools.ormcache_context('bundle', 'options.get("lang", "en_US")', keys=("website_id",))
def _get_asset_content(self, bundle, options, nodeAttrs=None):
options = dict(options,
inherit_branding=False, inherit_branding_auto=False,
edit_translations=False, translatable=False,
rendering_bundle=True)
options['website_id'] = self.env.context.get('website_id')
IrQweb = self.env['ir.qweb'].with_context(options)
def can_aggregate(url):
return not urls.url_parse(url).scheme and not urls.url_parse(url).netloc and not url.startswith('/web/assets')
# TODO: This helper can be used by any template that wants to embedd the backend.
# It is currently necessary because the ir.ui.view bundle inheritance does not
# match the module dependency graph.
def get_modules_order():
if request:
from odoo.addons.web.controllers.main import module_boot
return json.dumps(module_boot())
return '[]'
template = IrQweb._render(xmlid, {"get_modules_order": get_modules_order})
asset_paths = self.env['ir.asset']._get_asset_paths(bundle=bundle, css=True, js=True)
files = []
remains = []
for el in html.fragments_fromstring(template):
if isinstance(el, html.HtmlElement):
href = el.get('href', '')
src = el.get('src', '')
atype = el.get('type')
media = el.get('media')
for path, *_ in asset_paths:
ext = path.split('.')[-1]
is_js = ext in SCRIPT_EXTENSIONS
is_css = ext in STYLE_EXTENSIONS
if not is_js and not is_css:
continue
if can_aggregate(href) and (el.tag == 'style' or (el.tag == 'link' and el.get('rel') == 'stylesheet')):
if href.endswith('.sass'):
atype = 'text/sass'
elif href.endswith('.scss'):
atype = 'text/scss'
elif href.endswith('.less'):
atype = 'text/less'
if atype not in ('text/less', 'text/scss', 'text/sass'):
atype = 'text/css'
path = [segment for segment in href.split('/') if segment]
filename = get_resource_path(*path) if path else None
files.append({'atype': atype, 'url': href, 'filename': filename, 'content': el.text, 'media': media})
elif can_aggregate(src) and el.tag == 'script':
atype = 'text/javascript'
path = [segment for segment in src.split('/') if segment]
filename = get_resource_path(*path) if path else None
files.append({'atype': atype, 'url': src, 'filename': filename, 'content': el.text, 'media': media})
else:
remains.append((el.tag, OrderedDict(el.attrib), el.text))
mimetype = 'text/javascript' if is_js else 'text/%s' % ext
if can_aggregate(path):
segments = [segment for segment in path.split('/') if segment]
files.append({
'atype': mimetype,
'url': path,
'filename': get_resource_path(*segments) if segments else None,
'content': '',
'media': nodeAttrs and nodeAttrs.get('media'),
})
else:
# the other cases are ignored
pass
if is_js:
tag = 'script'
attributes = {
"type": mimetype,
"src": path,
}
else:
tag = 'link'
attributes = {
"type": mimetype,
"rel": "stylesheet",
"href": path,
'media': nodeAttrs and nodeAttrs.get('media'),
}
remains.append((tag, attributes, ''))
return (files, remains)
@@ -78,6 +78,7 @@
"access_res_users_apikeys_description_employee","API key wizard employees","model_res_users_apikeys_description","group_user",1,0,1,0
"access_res_users_apikeys_description_portal","API key wizard","model_res_users_apikeys_description","group_portal",1,0,1,0
"access_res_users_apikeys_show_employee","API key result employees","model_res_users_apikeys_show","group_user",1,0,1,0
"access_ir_asset_group_system","ir_asset_group_system","model_ir_asset","group_system",1,1,1,1
"access_ir_actions_group_system","ir_actions_group_system","model_ir_actions_actions","group_system",1,1,1,1
"access_ir_actions_act_window_system","ir_actions_act_window_system","model_ir_actions_act_window","group_system",1,1,1,1
"access_ir_actions_act_window_close_group_system","ir_actions_act_window_close_group_system","model_ir_actions_act_window_close","group_system",1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
78 access_res_users_apikeys_description_employee API key wizard employees model_res_users_apikeys_description group_user 1 0 1 0
79 access_res_users_apikeys_description_portal API key wizard model_res_users_apikeys_description group_portal 1 0 1 0
80 access_res_users_apikeys_show_employee API key result employees model_res_users_apikeys_show group_user 1 0 1 0
81 access_ir_asset_group_system ir_asset_group_system model_ir_asset group_system 1 1 1 1
82 access_ir_actions_group_system ir_actions_group_system model_ir_actions_actions group_system 1 1 1 1
83 access_ir_actions_act_window_system ir_actions_act_window_system model_ir_actions_act_window group_system 1 1 1 1
84 access_ir_actions_act_window_close_group_system ir_actions_act_window_close_group_system model_ir_actions_act_window_close group_system 1 1 1 1
+61
View File
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="asset_view_form" model="ir.ui.view">
<field name="model">ir.asset</field>
<field name="arch" type="xml">
<form string="Assets">
<sheet>
<group>
<group>
<field name="name"/>
<field name="bundle"/>
<field name="directive"/>
<field name="sequence"/>
<field name="active" widget="boolean_toggle"/>
</group>
<group>
<field name="target" attrs="{ 'invisible': [('directive', '!=', 'replace')] }"/>
<field name="glob"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="asset_view_tree" model="ir.ui.view">
<field name="model">ir.asset</field>
<field name="arch" type="xml">
<tree string="Assets">
<field name="name"/>
<field name="bundle"/>
<field name="sequence"/>
<field name="active"/>
</tree>
</field>
</record>
<record id="asset_view_search" model="ir.ui.view">
<field name="model">ir.asset</field>
<field name="arch" type="xml">
<search string="Assets">
<field name="name"/>
<field name="bundle"/>
<field name="directive"/>
<field name="sequence"/>
<field name="glob"/>
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
</search>
</field>
</record>
<record id="action_asset" model="ir.actions.act_window">
<field name="name">Assets</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">ir.asset</field>
<field name="view_id" ref="asset_view_tree"/>
<field name="context">{'search_default_active': 1}</field>
</record>
<menuitem action="action_asset" id="menu_action_asset" parent="base.next_id_9"/>
</odoo>
@@ -8,7 +8,55 @@
'depends': ['base'],
'installable': True,
'data': [
"data/ir_asset.xml",
"views/views.xml",
],
'auto_install': False,
'assets': {
'test_assetsbundle.bundle2': [
'test_assetsbundle/static/src/css/test_cssfile1.css',
],
'test_assetsbundle.bundle3': [
'test_assetsbundle/static/src/scss/test_file1.scss',
],
'test_assetsbundle.bundle4': [
'test_assetsbundle/static/src/js/test_jsfile1.js',
'test_assetsbundle/static/src/js/test_jsfile2.js',
'http://test.external.link/javascript1.js',
'test_assetsbundle/static/src/css/test_cssfile1.css',
'http://test.external.link/style1.css',
'test_assetsbundle/static/src/css/test_cssfile2.css',
'test_assetsbundle/static/src/js/test_jsfile3.js',
'http://test.external.link/javascript2.js',
'http://test.external.link/style2.css',
],
'test_assetsbundle.manifest1': [
'test_assetsbundle/static/src/*/**',
],
'test_assetsbundle.manifest2': [
'test_assetsbundle/static/src/js/test_jsfile1.js',
'test_assetsbundle/static/src/*/**',
],
'test_assetsbundle.manifest3': [
'test_assetsbundle/static/src/js/test_jsfile3.js',
'test_assetsbundle/static/src/*/**',
],
'test_assetsbundle.manifest4': [
'test_assetsbundle/static/src/js/test_jsfile3.js',
],
'test_assetsbundle.manifest5': [
'test_assetsbundle/static/src/js/test_jsfile1.js',
'test_assetsbundle/static/src/js/test_jsfile2.js',
'test_assetsbundle/static/src/js/test_jsfile3.js',
'test_assetsbundle/static/src/js/test_jsfile4.js',
],
'test_assetsbundle.manifest6': [
('include', 'test_assetsbundle.manifest4'),
],
'test_assetsbundle.manifest_multi_module1': [],
}
}
@@ -8,6 +8,4 @@ class TestAssetsBundleController(Controller):
@route('/test_assetsbundle/js', type='http', auth='user')
def bundle(self):
env = request.env(user=SUPERUSER_ID)
bundle = env.ref('test_assetsbundle.bundle1')
views = env['ir.ui.view'].search([('inherit_id', '=', bundle.id)])
return views.with_context(check_view_ids=views.ids)._render_template('test_assetsbundle.template1')
return env['ir.ui.view']._render_template('test_assetsbundle.template1')
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="test_assetsbundle.bundle1" model="ir.asset">
<field name="name">Bundle 1</field>
<field name="bundle">test_assetsbundle.bundle1</field>
<field name="glob">test_assetsbundle/static/src/js/test_jsfile[!4].js</field>
</record>
</data>
</odoo>
@@ -0,0 +1 @@
<!-- Can access me -->
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,4 @@
<odoo>
<template id="test_assetsbundle.bundle1">
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile1.js"></script>
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile2.js"></script>
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile3.js"></script>
</template>
<template id="test_assetsbundle.template1" name="test template 1">
&lt;!DOCTYPE html&gt;
<html>
@@ -16,29 +10,6 @@
</html>
</template>
<template id="test_assetsbundle.bundle2">
<link rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile1.css"/>
</template>
<template id="test_assetsbundle.bundle3">
<link rel="stylesheet" type="text/scss" href="/test_assetsbundle/static/src/scss/test_file1.scss"/>
</template>
<template id="test_assetsbundle.bundle4">
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile1.js"></script>
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile2.js"></script>
<script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
<link rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile1.css"/>
<link rel="stylesheet" href="http://test.external.link/style1.css"/>
<link rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile2.css"/>
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile3.js"></script>
<script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
<link rel="stylesheet" href="http://test.external.link/style2.css"/>
</template>
<template id="test_assetsbundle.template2" name="test template 2">
&lt;!DOCTYPE html&gt;
<html>
+11 -11
View File
@@ -55,7 +55,7 @@ from .tools.func import lazy_property
from .tools import ustr, consteq, frozendict, pycompat, unique, date_utils
from .tools.mimetypes import guess_mimetype
from .tools._vendor import sessions
from .modules.module import module_manifest
from .modules.module import read_manifest
_logger = logging.getLogger(__name__)
rpc_request = logging.getLogger(__name__ + '.rpc.request')
@@ -1303,21 +1303,21 @@ class Root(object):
controllers and configure them. """
# TODO should we move this to ir.http so that only configured modules are served ?
statics = {}
manifests = addons_manifest
for addons_path in odoo.addons.__path__:
for module in sorted(os.listdir(str(addons_path))):
if module not in addons_manifest:
if module not in manifests:
# Deal with the manifest first
mod_path = opj(addons_path, module)
manifest_path = module_manifest(mod_path)
manifest = read_manifest(addons_path, module)
if not manifest or (not manifest.get('installable', True) and 'assets' not in manifest):
continue
manifest['addons_path'] = addons_path
manifests[module] = manifest
# Then deal with the statics
path_static = opj(addons_path, module, 'static')
if manifest_path and os.path.isdir(path_static):
with open(manifest_path, 'rb') as fd:
manifest_data = fd.read()
manifest = ast.literal_eval(pycompat.to_text(manifest_data))
if not manifest.get('installable', True):
continue
manifest['addons_path'] = addons_path
if os.path.isdir(path_static):
_logger.debug("Loading %s", module)
addons_manifest[module] = manifest
statics['/%s/static' % module] = path_static
if statics:
+8
View File
@@ -249,6 +249,14 @@ def module_manifest(path):
if os.path.isfile(opj(path, manifest_name)):
return opj(path, manifest_name)
def read_manifest(addons_path, module):
mod_path = opj(addons_path, module)
manifest_path = module_manifest(mod_path)
if manifest_path:
with tools.file_open(manifest_path, 'r') as fd:
manifest_data = fd.read()
return ast.literal_eval(manifest_data)
def get_module_root(path):
"""
Get closest module's root beginning from path
+50
View File
@@ -174,6 +174,13 @@ class RecordCapturer:
# ------------------------------------------------------------
class OdooSuite(unittest.suite.TestSuite):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from odoo.http import root
if not root._loaded:
root.load_addons()
root._loaded = True
if sys.version_info < (3, 8):
# Partial backport of bpo-24412, merged in CPython 3.8
@@ -282,6 +289,27 @@ class MetaCase(type):
cls.test_sequence = 0
def _normalize_arch_for_assert(arch_string, parser_method="xml"):
"""Takes some xml and normalize it to make it comparable to other xml
in particular, blank text is removed, and the output is pretty-printed
:param arch_string: the string representing an XML arch
:type arch_string: str
:param parser_method: an string representing which lxml.Parser class to use
when normalizing both archs. Takes either "xml" or "html"
:type parser_method: str
:return: the normalized arch
:rtype str:
"""
Parser = None
if parser_method == 'xml':
Parser = etree.XMLParser
elif parser_method == 'html':
Parser = etree.HTMLParser
parser = Parser(remove_blank_text=True)
arch_string = etree.fromstring(arch_string, parser=parser)
return etree.tostring(arch_string, pretty_print=True, encoding='unicode')
class BaseCase(unittest.TestCase, metaclass=MetaCase):
""" Subclass of TestCase for Odoo-specific code. This class is abstract and
expects self.registry, self.cr and self.uid to be initialized by subclasses.
@@ -607,6 +635,28 @@ class BaseCase(unittest.TestCase, metaclass=MetaCase):
for c1, c2 in izip_longest(n1, n2):
self.assertTreesEqual(c1, c2, msg)
def _assertXMLEqual(self, original, expected, parser="xml"):
"""Asserts that two xmls archs are equal
:param original: the xml arch to test
:type original: str
:param expected: the xml arch of reference
:type expected: str
:param parser: an string representing which lxml.Parser class to use
when normalizing both archs. Takes either "xml" or "html"
:type parser: str
"""
if original:
original = _normalize_arch_for_assert(original, parser)
if expected:
expected = _normalize_arch_for_assert(expected, parser)
self.assertEqual(original, expected)
def assertXMLEqual(self, original, expected):
return self._assertXMLEqual(original, expected)
def assertHTMLEqual(self, original, expected):
return self._assertXMLEqual(original, expected, 'html')
savepoint_seq = itertools.count()
+1
View File
@@ -0,0 +1 @@
/** Should not be read by anyone */
+1
View File
@@ -0,0 +1 @@
<!-- Should not be read by anyone -->