diff --git a/addons/bus/static/src/js/web_client_bus.js b/addons/bus/static/src/js/web_client_bus.js index 6683b56509b..759ec2dc945 100644 --- a/addons/bus/static/src/js/web_client_bus.js +++ b/addons/bus/static/src/js/web_client_bus.js @@ -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'); diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index a5e46ae3a99..b5d3f684646 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -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/', 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/', 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): diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index fd83e877969..cba920611eb 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -7,7 +7,7 @@ from odoo import api, models from odoo.http import request from odoo.tools import ustr -from odoo.addons.web.controllers.main import 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() diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js index d38b7725b81..b9ff01c7dac 100644 --- a/addons/web/static/src/js/core/session.js +++ b/addons/web/static/src/js/core/session.js @@ -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 = []; diff --git a/addons/web/static/src/js/core/translation.js b/addons/web/static/src/js/core/translation.js index 5335f4716a7..169cf9422e8 100644 --- a/addons/web/static/src/js/core/translation.js +++ b/addons/web/static/src/js/core/translation.js @@ -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); }); } diff --git a/addons/web/static/src/js/services/session.js b/addons/web/static/src/js/services/session.js index 35720f8185f..2ce9feecd44 100644 --- a/addons/web/static/src/js/services/session.js +++ b/addons/web/static/src/js/services/session.js @@ -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; diff --git a/addons/web/tests/test_js.py b/addons/web/tests/test_js.py index 4d97e5f28d0..1a3c1dcdb81 100644 --- a/addons/web/tests/test_js.py +++ b/addons/web/tests/test_js.py @@ -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`. diff --git a/addons/web/tests/test_serving_base.py b/addons/web/tests/test_serving_base.py index d7d83c2532a..0b55d599cd1 100644 --- a/addons/web/tests/test_serving_base.py +++ b/addons/web/tests/test_serving_base.py @@ -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): ''' } - 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): ''' } - 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 = '' diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml index 1dc19a75ee3..6acca5561a5 100644 --- a/addons/web/views/report_templates.xml +++ b/addons/web/views/report_templates.xml @@ -48,6 +48,12 @@ <t t-esc="title or 'Odoo Report'"/> + + + diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index c4c93ddc7be..63290d54224 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -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): diff --git a/addons/web_editor/models/assets.py b/addons/web_editor/models/assets.py index 4178335624c..17da5c08eb4 100644 --- a/addons/web_editor/models/assets.py +++ b/addons/web_editor/models/assets.py @@ -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': """ - - - %(new_url)s - - - """ % { - '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 diff --git a/addons/web_editor/static/src/js/common/ace.js b/addons/web_editor/static/src/js/common/ace.js index 0c3198dee44..0f353f31681 100644 --- a/addons/web_editor/static/src/js/common/ace.js +++ b/addons/web_editor/static/src/js/common/ace.js @@ -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 = $('', { - 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); diff --git a/addons/web_editor/static/src/js/wysiwyg/test_root.js b/addons/web_editor/static/src/js/wysiwyg/test_root.js index c1bfc1020ba..0a116f79ed4 100644 --- a/addons/web_editor/static/src/js/wysiwyg/test_root.js +++ b/addons/web_editor/static/src/js/wysiwyg/test_root.js @@ -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...) diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index be21d4d6c77..bf137ae5f03 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -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', diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py index 5df75fb65c0..b55ec43dbf1 100644 --- a/addons/website/models/__init__.py +++ b/addons/website/models/__init__.py @@ -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 diff --git a/addons/website/models/assets.py b/addons/website/models/assets.py index 661aa8ffc64..170d3b43f49 100644 --- a/addons/website/models/assets.py +++ b/addons/website/models/assets.py @@ -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: diff --git a/addons/website/models/ir_asset.py b/addons/website/models/ir_asset.py new file mode 100644 index 00000000000..c62e06f9641 --- /dev/null +++ b/addons/website/models/ir_asset.py @@ -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 diff --git a/addons/website/models/ir_module_module.py b/addons/website/models/ir_module_module.py index 7d88a70bae1..09e5d4b8c2d 100644 --- a/addons/website/models/ir_module_module.py +++ b/addons/website/models/ir_module_module.py @@ -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'] diff --git a/addons/website/models/theme_models.py b/addons/website/models/theme_models.py index 40eebe662a0..cdb0535758e 100644 --- a/addons/website/models/theme_models.py +++ b/addons/website/models/theme_models.py @@ -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' diff --git a/addons/website/security/ir.model.access.csv b/addons/website/security/ir.model.access.csv index c3bcd6aa304..6b5c4fa8fb5 100644 --- a/addons/website/security/ir.model.access.csv +++ b/addons/website/security/ir.model.access.csv @@ -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 diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py index 9d89ba06b88..0f455757ae9 100644 --- a/addons/website/tests/test_qweb.py +++ b/addons/website/tests/test_qweb.py @@ -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, (""" + self.assertHTMLEqual(html, (""" - - - + + + - + diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py index d76bf22cf81..e89c1d3f1bc 100644 --- a/addons/website/tests/test_ui.py +++ b/addons/website/tests/test_ui.py @@ -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': """ - - - - """, + + 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') diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py index eeca54dc27a..c6da481d16a 100644 --- a/addons/website/tests/test_views.py +++ b/addons/website/tests/test_views.py @@ -862,7 +862,7 @@ class TestCowViewSaving(TestViewSavingCommon): 'name': 'Main layout', 'mode': 'extension', 'inherit_id': base_view.id, - 'arch': '', + 'arch': '', 'key': '_website.layout', }) @@ -875,7 +875,7 @@ class TestCowViewSaving(TestViewSavingCommon): 'name': 'Forum Layout', 'mode': 'primary', 'inherit_id': inherit_view.id, - 'arch': '', + 'arch': '', 'key': '_website_forum.layout', })]) diff --git a/addons/website/views/ir_asset_views.xml b/addons/website/views/ir_asset_views.xml new file mode 100644 index 00000000000..95fbc6523ba --- /dev/null +++ b/addons/website/views/ir_asset_views.xml @@ -0,0 +1,13 @@ + + + + ir.asset.form.inherit.website + ir.asset + + + + + + + + diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml index e5cd8fec3b9..1ffa78546ff 100644 --- a/addons/website/views/website_views.xml +++ b/addons/website/views/website_views.xml @@ -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')})]"/> - + diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst index 70c4ca5777d..a170690db00 100644 --- a/doc/reference/javascript_reference.rst +++ b/doc/reference/javascript_reference.rst @@ -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 +`` 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: - +.. 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 - - + + 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 - + '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 `_ file. +.. _glob: + https://en.wikipedia.org/wiki/Glob_(programming) + .. _.appendTo(): https://api.jquery.com/appendTo/ diff --git a/odoo/addons/base/__manifest__.py b/odoo/addons/base/__manifest__.py index 2e7f552f237..cbea1700eda 100644 --- a/odoo/addons/base/__manifest__.py +++ b/odoo/addons/base/__manifest__.py @@ -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', diff --git a/odoo/addons/base/models/__init__.py b/odoo/addons/base/models/__init__.py index bdc3d0b1a0e..09b2f76e34d 100644 --- a/odoo/addons/base/models/__init__.py +++ b/odoo/addons/base/models/__init__.py @@ -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 diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py index 2ae9cd3c222..636cf05b91f 100644 --- a/odoo/addons/base/models/assetsbundle.py +++ b/odoo/addons/base/models/assetsbundle.py @@ -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()) diff --git a/odoo/addons/base/models/ir_asset.py b/odoo/addons/base/models/ir_asset.py new file mode 100644 index 00000000000..51a1c412703 --- /dev/null +++ b/odoo/addons/base/models/ir_asset.py @@ -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)) diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py index ce1a72e8a06..1c98f359a58 100644 --- a/odoo/addons/base/models/ir_module.py +++ b/odoo/addons/base/models/ir_module.py @@ -148,6 +148,7 @@ STATES = [ ('to install', 'To be installed'), ] + class Module(models.Model): _name = "ir.module.module" _rec_name = "shortdesc" diff --git a/odoo/addons/base/models/ir_qweb.py b/odoo/addons/base/models/ir_qweb.py index 64860d2949f..c861dc51afb 100644 --- a/odoo/addons/base/models/ir_qweb.py +++ b/odoo/addons/base/models/ir_qweb.py @@ -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) diff --git a/odoo/addons/base/security/ir.model.access.csv b/odoo/addons/base/security/ir.model.access.csv index a049ca75911..0908b667918 100644 --- a/odoo/addons/base/security/ir.model.access.csv +++ b/odoo/addons/base/security/ir.model.access.csv @@ -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 diff --git a/odoo/addons/base/views/ir_asset_views.xml b/odoo/addons/base/views/ir_asset_views.xml new file mode 100644 index 00000000000..673fd7eb6c1 --- /dev/null +++ b/odoo/addons/base/views/ir_asset_views.xml @@ -0,0 +1,61 @@ + + + + ir.asset + +
+ + + + + + + + + + + + + + + +
+
+
+ + + ir.asset + + + + + + + + + + + + ir.asset + + + + + + + + + + + + + + Assets + ir.actions.act_window + ir.asset + + {'search_default_active': 1} + + + +
diff --git a/odoo/addons/test_assetsbundle/__manifest__.py b/odoo/addons/test_assetsbundle/__manifest__.py index 42a67935b50..cdf0f63f951 100644 --- a/odoo/addons/test_assetsbundle/__manifest__.py +++ b/odoo/addons/test_assetsbundle/__manifest__.py @@ -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': [], + } } diff --git a/odoo/addons/test_assetsbundle/controllers/main.py b/odoo/addons/test_assetsbundle/controllers/main.py index f6da2ab929b..9fb1157b6b4 100644 --- a/odoo/addons/test_assetsbundle/controllers/main.py +++ b/odoo/addons/test_assetsbundle/controllers/main.py @@ -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') diff --git a/odoo/addons/test_assetsbundle/data/ir_asset.xml b/odoo/addons/test_assetsbundle/data/ir_asset.xml new file mode 100644 index 00000000000..01da49e34ad --- /dev/null +++ b/odoo/addons/test_assetsbundle/data/ir_asset.xml @@ -0,0 +1,10 @@ + + + + + Bundle 1 + test_assetsbundle.bundle1 + test_assetsbundle/static/src/js/test_jsfile[!4].js + + + diff --git a/odoo/addons/test_assetsbundle/static/accessible.xml b/odoo/addons/test_assetsbundle/static/accessible.xml new file mode 100644 index 00000000000..25029de18b6 --- /dev/null +++ b/odoo/addons/test_assetsbundle/static/accessible.xml @@ -0,0 +1 @@ + diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py index 5cd488c0c10..ac6d7f30692 100644 --- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py +++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py @@ -2,24 +2,113 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import Counter -import datetime -import errno from lxml import etree import os import time +from unittest import skip from unittest.mock import patch +import textwrap +import pathlib +import lxml +import base64 -from odoo import api +from odoo import api, http +from odoo.addons import __path__ as ADDONS_PATH from odoo.addons.base.models.assetsbundle import AssetsBundle +from odoo.addons.base.models.ir_asset import AssetPaths from odoo.addons.base.models.ir_attachment import IrAttachment -from odoo.modules.module import get_resource_path +from odoo.modules.module import get_resource_path, read_manifest from odoo.tests import HttpCase, tagged from odoo.tests.common import TransactionCase +from odoo.addons.base.models.qweb import QWebException +from odoo.tools import mute_logger, func + GETMTINE = os.path.getmtime -class FileTouchable(TransactionCase): +class TestAddonPaths(TransactionCase): + def test_operations(self): + asset_paths = AssetPaths() + self.assertFalse(asset_paths.list) + + asset_paths.append(['a', 'c', 'd'], 'module1', 'bundle1') + self.assertEqual(asset_paths.list, [ + ('a', 'module1', 'bundle1'), + ('c', 'module1', 'bundle1'), + ('d', 'module1', 'bundle1'), + ]) + + # append with a duplicate of 'c' + asset_paths.append(['c', 'f'], 'module2', 'bundle2') + self.assertEqual(asset_paths.list, [ + ('a', 'module1', 'bundle1'), + ('c', 'module1', 'bundle1'), + ('d', 'module1', 'bundle1'), + ('f', 'module2', 'bundle2'), + ]) + + # insert with a duplicate of 'c' after 'c' + asset_paths.insert(['c', 'e'], 'module3', 'bundle3', 3) + self.assertEqual(asset_paths.list, [ + ('a', 'module1', 'bundle1'), + ('c', 'module1', 'bundle1'), + ('d', 'module1', 'bundle1'), + ('e', 'module3', 'bundle3'), + ('f', 'module2', 'bundle2'), + ]) + + # insert with a duplicate of 'd' before 'd' + asset_paths.insert(['b', 'd'], 'module4', 'bundle4', 1) + self.assertEqual(asset_paths.list, [ + ('a', 'module1', 'bundle1'), + ('b', 'module4', 'bundle4'), + ('c', 'module1', 'bundle1'), + ('d', 'module1', 'bundle1'), + ('e', 'module3', 'bundle3'), + ('f', 'module2', 'bundle2'), + ]) + + # remove + asset_paths.remove(['c', 'd', 'g'], 'module5', 'bundle5') + self.assertEqual(asset_paths.list, [ + ('a', 'module1', 'bundle1'), + ('b', 'module4', 'bundle4'), + ('e', 'module3', 'bundle3'), + ('f', 'module2', 'bundle2'), + ]) + + +class AddonManifestPatched(TransactionCase): + + test_assetsbundle_manifest = None + for path in ADDONS_PATH: + manifest = read_manifest(path, 'test_assetsbundle') + if manifest: + manifest['addons_path'] = path + test_assetsbundle_manifest = manifest + break + + def tearDown(self): + super().tearDown() + self.env.registry._init_modules = self.__genuine_registry_modules + http.addons_manifest = self.__genuine_addons_manifest + + def setUp(self): + super().setUp() + self.__genuine_registry_modules = self.env.registry._init_modules + self.env.registry._init_modules = func.lazy(lambda: set(self.installed_modules)) + + self.__genuine_addons_manifest = http.addons_manifest + http.addons_manifest = func.lazy(lambda: self.manifests) + + self.installed_modules = ['base', 'test_assetsbundle'] + self.manifests = { + 'test_assetsbundle': self.test_assetsbundle_manifest, + } + + +class FileTouchable(AddonManifestPatched): def setUp(self): super(FileTouchable, self).setUp() self.touches = {} @@ -32,21 +121,20 @@ class FileTouchable(TransactionCase): class TestJavascriptAssetsBundle(FileTouchable): def setUp(self): super(TestJavascriptAssetsBundle, self).setUp() - self.jsbundle_xmlid = 'test_assetsbundle.bundle1' - self.cssbundle_xmlid = 'test_assetsbundle.bundle2' + self.jsbundle_name = 'test_assetsbundle.bundle1' + self.cssbundle_name = 'test_assetsbundle.bundle2' self.env['res.lang']._activate_lang('ar_SY') - - def _get_asset(self, xmlid, env=None): + def _get_asset(self, bundle, env=None): env = (env or self.env) - files, remains = env['ir.qweb']._get_asset_content(xmlid, env.context) - return AssetsBundle(xmlid, files, env=env) + files, _ = env['ir.qweb']._get_asset_content(bundle, env.context) + return AssetsBundle(bundle, files, env=env) def _any_ira_for_bundle(self, extension, lang=None): """ Returns all ir.attachments associated to a bundle, regardless of the verion. """ user_direction = self.env['res.lang']._lang_get(lang or self.env.user.lang).direction - bundle = self.jsbundle_xmlid if extension in ['js', 'min.js'] else self.cssbundle_xmlid + bundle = self.jsbundle_name if extension in ['js', 'min.js'] else self.cssbundle_name rtl = 'rtl/' if extension in ['css', 'min.css'] and user_direction == 'rtl' else '' url = f'/web/assets/%-%/{rtl}{bundle}.{extension}' domain = [('url', '=like', url)] @@ -63,7 +151,7 @@ class TestJavascriptAssetsBundle(FileTouchable): """ Checks that a bundle creates an ir.attachment record when its `js` method is called for the first time and this ir.attachment is different depending on `is_minified` param. """ - self.bundle = self._get_asset(self.jsbundle_xmlid, env=self.env) + self.bundle = self._get_asset(self.jsbundle_name, env=self.env) # there shouldn't be any minified attachment associated to this bundle self.assertEqual(len(self._any_ira_for_bundle('min.js')), 0, @@ -99,7 +187,7 @@ class TestJavascriptAssetsBundle(FileTouchable): """ Checks that the bundle's cache is working, i.e. that the bundle creates only one ir.attachment record when rendered multiple times. """ - bundle0 = self._get_asset(self.jsbundle_xmlid) + bundle0 = self._get_asset(self.jsbundle_name) bundle0.js() self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, @@ -109,7 +197,7 @@ class TestJavascriptAssetsBundle(FileTouchable): ira0 = self._any_ira_for_bundle('min.js') date0 = ira0.create_date - bundle1 = self._get_asset(self.jsbundle_xmlid) + bundle1 = self._get_asset(self.jsbundle_name) bundle1.js() self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, @@ -127,13 +215,13 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_03_date_invalidation(self): """ Checks that a bundle is invalidated when one of its assets' modification date is changed. """ - bundle0 = self._get_asset(self.jsbundle_xmlid) + bundle0 = self._get_asset(self.jsbundle_name) bundle0.js() last_modified0 = bundle0.last_modified version0 = bundle0.version path = get_resource_path('test_assetsbundle', 'static', 'src', 'js', 'test_jsfile1.js') - bundle1 = self._get_asset(self.jsbundle_xmlid) + bundle1 = self._get_asset(self.jsbundle_name) with self._touch(path): bundle1.js() @@ -152,7 +240,7 @@ class TestJavascriptAssetsBundle(FileTouchable): """ Checks that a bundle is invalidated when its content is modified by adding a file to source. """ - bundle0 = self._get_asset(self.jsbundle_xmlid) + bundle0 = self._get_asset(self.jsbundle_name) bundle0.js() files0 = bundle0.files version0 = bundle0.version @@ -160,22 +248,13 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, "there should be one minified attachment associated to this bundle") - view_arch = """ - - - - + """ % format_data).encode('utf8')) - def test_21_exteral_lib_assets_debug_mode(self): + def test_21_external_lib_assets_debug_mode(self): html = self.env['ir.ui.view']._render_template('test_assetsbundle.template2', {"debug": "assets"}) attachments = self.env['ir.attachment'].search([('url', '=like', '%/test_assetsbundle.bundle4.js')]) self.assertEqual(len(attachments), 1) - asset_data_css = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[0] - asset_data_js = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[1] + asset_data_css = etree.HTML(html).xpath('//*[@data-asset-bundle]')[0] + asset_data_js = etree.HTML(html).xpath('//*[@data-asset-bundle]')[1] format_data = { - "asset_xmlid_css": asset_data_css.attrib.get('data-asset-xmlid'), + "asset_bundle_css": asset_data_css.attrib.get('data-asset-bundle'), "asset_version_css": asset_data_css.attrib.get('data-asset-version'), - "asset_xmlid_js": asset_data_js.attrib.get('data-asset-xmlid'), + "asset_bundle_js": asset_data_js.attrib.get('data-asset-bundle'), "asset_version_js": asset_data_js.attrib.get('data-asset-version'), "css": '/web/assets/debug/test_assetsbundle.bundle4.css', "js": '/web/assets/debug/test_assetsbundle.bundle4.js', @@ -601,13 +662,13 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(html.strip(), (""" - - - + + + - + @@ -625,6 +686,7 @@ class TestAssetsBundleInBrowser(HttpCase): login="admin" ) + @skip("Feature Regression") def test_02_js_interpretation_inline(self): """ Checks that the javascript of a bundle is correctly interpretet when mixed with inline. """ @@ -651,11 +713,38 @@ class TestAssetsBundleInBrowser(HttpCase): login="admin", ) + # LPE Fixme + # Review point @al: is this really what we want people to do ? + def test_03_js_interpretation_recommended_new_method(self): + """ Checks the feature of test_02 is still produceable, but in another way + '/web/content//', + """ + code = b'const d = 4;' + attach = self.env['ir.attachment'].create({ + 'name': 'CustomJscode.js', + 'mimetype': 'text/javascript', + 'datas': base64.b64encode(code), + }) + # Use this route (filename is necessary) + custom_url = '/web/content/%s/%s' % (attach.id, attach.name) + attach.url = custom_url + + self.env['ir.asset'].create({ + 'name': 'lol', + 'bundle': 'test_assetsbundle.bundle1', + 'glob': custom_url, + }) + self.browser_js( + "/test_assetsbundle/js", + "a + b + c + d === 10 ? console.log('test successful') : console.log('error')", + login="admin", + ) + class TestAssetsBundleWithIRAMock(FileTouchable): def setUp(self): super(TestAssetsBundleWithIRAMock, self).setUp() - self.stylebundle_xmlid = 'test_assetsbundle.bundle3' + self.stylebundle_name = 'test_assetsbundle.bundle3' self.counter = counter = Counter() # patch methods 'create' and 'unlink' of model 'ir.attachment' @@ -675,8 +764,8 @@ class TestAssetsBundleWithIRAMock(FileTouchable): self.patch(IrAttachment, 'unlink', unlink) def _get_asset(self): - files, remains = self.env['ir.qweb']._get_asset_content(self.stylebundle_xmlid, {}) - return AssetsBundle(self.stylebundle_xmlid, files, env=self.env) + files, _ = self.env['ir.qweb']._get_asset_content(self.stylebundle_name, {}) + return AssetsBundle(self.stylebundle_name, files, env=self.env) def _bundle(self, asset, should_create, should_unlink): self.counter.clear() @@ -712,3 +801,1082 @@ class TestAssetsBundleWithIRAMock(FileTouchable): # Compile a fourth time, without changes self._bundle(self._get_asset(), False, False) + + +@tagged('assets_manifest') +class TestAssetsManifest(AddonManifestPatched): + + def make_asset_view(self, asset_key, t_call_assets_attrs=None): + default_attrs = { + 't-js': 'true', + 't-css': 'false', + } + if t_call_assets_attrs: + default_attrs.update(t_call_assets_attrs) + + attrs = ' '.join(['%s="%s"' % (k, v) for k, v in default_attrs.items()]) + arch = ''' +
+ +
+ ''' % { + 'asset_key': asset_key, + 'attrs': attrs + } + + view = self.env['ir.ui.view'].create({ + 'name': 'test asset', + 'arch': arch, + 'type': 'qweb', + }) + return view + + def assertStringEqual(self, reference, tested): + tested = textwrap.dedent(tested).strip() + reference = reference.strip() + self.assertEqual(tested, reference) + + def test_01_globmanifest(self): + view = self.make_asset_view('test_assetsbundle.manifest1') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest1.min.js')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest1' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.manifest1' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest1' */ + var c=3;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.manifest1' */ + var d=4; + ''' + ) + + def test_02_globmanifest_no_duplicates(self): + view = self.make_asset_view('test_assetsbundle.manifest2') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest2.min.js')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest2' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.manifest2' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest2' */ + var c=3;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.manifest2' */ + var d=4; + ''' + ) + + def test_03_globmanifest_file_before(self): + view = self.make_asset_view('test_assetsbundle.manifest3') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest3.min.js')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest3' */ + var c=3;; + + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest3' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.manifest3' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.manifest3' */ + var d=4; + ''' + ) + + def test_04_globmanifest_with_irasset(self): + view = self.make_asset_view('test_assetsbundle.manifest4') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.manifest4', + 'glob': 'test_assetsbundle/static/src/js/test_jsfile1.js', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4.min.js')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest4' */ + var c=3;; + + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1; + ''' + ) + + def test_05_only_irasset(self): + view = self.make_asset_view('test_assetsbundle.irasset1') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.irasset1', + 'glob': 'test_assetsbundle/static/src/js/test_jsfile1.js', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irasset1.min.js')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.irasset1' */ + var a=1; + ''' + ) + + def test_06_replace(self): + view = self.make_asset_view('test_assetsbundle.manifest1') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.manifest1', + 'directive': 'replace', + 'target': 'test_assetsbundle/static/src/js/test_jsfile1.js', + 'glob': 'http://external.link/external.js', + }) + rendered = view._render() + html_tree = lxml.etree.fromstring(rendered) + scripts = html_tree.findall('script') + self.assertEqual(len(scripts), 2) + self.assertEqual(scripts[0].get('src'), 'http://external.link/external.js') + + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest1')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.manifest1' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest1' */ + var c=3;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.manifest1' */ + var d=4; + ''' + ) + + def test_06_2_replace(self): + view = self.make_asset_view('test_assetsbundle.manifest4') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.manifest4', + 'directive': 'replace', + 'glob': 'test_assetsbundle/static/src/js/test_jsfile1.js', + 'target': 'test_assetsbundle/static/src/js/test_jsfile3.js', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1; + ''' + ) + + def test_06_3_replace_globs(self): + view = self.make_asset_view('test_assetsbundle.manifest4') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'prepend', + 'bundle': 'test_assetsbundle.manifest4', + 'glob': 'test_assetsbundle/static/src/js/test_jsfile4.js', + }) + # asset is now: js_file4 ; js_file3 + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.manifest4', + 'directive': 'replace', + 'glob': 'test_assetsbundle/static/src/js/test_jsfile[12].js', + 'target': 'test_assetsbundle/static/src/js/test_jsfile[45].js', + }) + # asset is now: js_file1 ; js_file2 ; js_file3 + # because js_file is replaced by 1 and 2 + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.manifest4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest4' */ + var c=3; + ''' + ) + + def test_07_remove(self): + view = self.make_asset_view('test_assetsbundle.manifest5') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.manifest5', + 'directive': 'remove', + 'glob': 'test_assetsbundle/static/src/js/test_jsfile2.js', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest5')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest5' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest5' */ + var c=3;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.manifest5' */ + var d=4; + ''' + ) + + def test_08_remove_inexistent_file(self): + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.remove_error', + 'glob': '/test_assetsbundle/static/src/js/test_jsfile1.js', + }) + + view = self.make_asset_view('test_assetsbundle.remove_error') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.remove_error', + 'directive': 'remove', + 'glob': 'test_assetsbundle/static/src/js/test_doesntexist.js', + }) + with self.assertRaises(Exception) as cm: + view._render() + self.assertTrue( + "test_assetsbundle/static/src/js/test_doesntexist.js not found" in cm.exception.message + ) + + def test_09_remove_wholeglob(self): + view = self.make_asset_view('test_assetsbundle.manifest2') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.manifest2', + 'directive': 'remove', + 'glob': 'test_assetsbundle/static/src/**/*', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest2.js')], order='create_date DESC', limit=1) + # indeed everything in the bundle matches the glob, so there is no attachment + self.assertFalse(attach) + + def test_10_prepend(self): + view = self.make_asset_view('test_assetsbundle.manifest4') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'prepend', + 'bundle': 'test_assetsbundle.manifest4', + 'glob': 'test_assetsbundle/static/src/js/test_jsfile1.js', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest4' */ + var c=3; + ''' + ) + + def test_11_include(self): + view = self.make_asset_view('test_assetsbundle.irasset_include1') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'include', + 'bundle': 'test_assetsbundle.irasset_include1', + 'glob': 'test_assetsbundle.manifest6', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irasset_include1')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.irasset_include1' */ + var c=3; + ''' + ) + + def test_12_include2(self): + view = self.make_asset_view('test_assetsbundle.manifest6') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest6')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest6' */ + var c=3; + ''' + ) + + def test_13_include_circular(self): + view = self.make_asset_view('test_assetsbundle.irasset_include1') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'include', + 'bundle': 'test_assetsbundle.irasset_include1', + 'glob': 'test_assetsbundle.irasset_include2', + }) + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'include', + 'bundle': 'test_assetsbundle.irasset_include2', + 'glob': 'test_assetsbundle.irasset_include1', + }) + + with self.assertRaises(QWebException) as cm: + view._render() + self.assertTrue(cm.exception.error) + self.assertFalse(isinstance(cm.exception.error, RecursionError)) + self.assertTrue( + 'Circular assets bundle declaration:' in cm.exception.message + ) + + def test_13_2_include_recursive_sibling(self): + view = self.make_asset_view('test_assetsbundle.irasset_include1') + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'include', + 'bundle': 'test_assetsbundle.irasset_include1', + 'glob': 'test_assetsbundle.irasset_include2', + }) + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'include', + 'bundle': 'test_assetsbundle.irasset_include2', + 'glob': 'test_assetsbundle.irasset_include3', + }) + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'include', + 'bundle': 'test_assetsbundle.irasset_include2', + 'glob': 'test_assetsbundle.irasset_include4', + }) + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'directive': 'include', + 'bundle': 'test_assetsbundle.irasset_include4', + 'glob': 'test_assetsbundle.irasset_include3', + }) + self.env['ir.asset'].create({ + 'name': 'test_jsfile4', + 'bundle': 'test_assetsbundle.irasset_include3', + 'glob': 'test_assetsbundle/static/src/js/test_jsfile1.js', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irasset_include1')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.irasset_include1' */ + var a=1; + ''' + ) + + def test_14_other_module(self): + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_other.mockmanifest1': [ + ('include', 'test_assetsbundle.manifest4'), + ] + } + } + view = self.make_asset_view('test_other.mockmanifest1') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_other.mockmanifest1')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_other.mockmanifest1' */ + var c=3; + ''' + ) + + def test_15_other_module_append(self): + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_assetsbundle.manifest4': [ + 'test_assetsbundle/static/src/js/test_jsfile1.js', + ] + } + } + view = self.make_asset_view('test_assetsbundle.manifest4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest4' */ + var c=3;; + + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1; + ''' + ) + + def test_16_other_module_prepend(self): + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_assetsbundle.manifest4': [ + ('prepend', 'test_assetsbundle/static/src/js/test_jsfile1.js'), + ] + } + } + view = self.make_asset_view('test_assetsbundle.manifest4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest4' */ + var c=3; + ''' + ) + + def test_17_other_module_replace(self): + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_assetsbundle.manifest4': [ + ('replace', 'test_assetsbundle/static/src/js/test_jsfile3.js', 'test_assetsbundle/static/src/js/test_jsfile1.js'), + ] + } + } + view = self.make_asset_view('test_assetsbundle.manifest4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1; + ''' + ) + + def test_17_other_module_remove(self): + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_assetsbundle.manifest4': [ + ('remove', 'test_assetsbundle/static/src/js/test_jsfile3.js'), + ('append', 'test_assetsbundle/static/src/js/test_jsfile1.js'), + ] + } + } + view = self.make_asset_view('test_assetsbundle.manifest4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1; + ''' + ) + + def test_18_other_module_external(self): + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_assetsbundle.manifest4': [ + 'http://external.link/external.js', + ] + } + } + view = self.make_asset_view('test_assetsbundle.manifest4') + rendered = view._render() + html_tree = lxml.etree.fromstring(rendered) + scripts = html_tree.findall('script') + self.assertEqual(len(scripts), 2) + self.assertEqual(scripts[0].get('src'), 'http://external.link/external.js') + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest4' */ + var c=3; + ''' + ) + + # + # LPE Fixme: Warning, this matches a change in behavior + # Before this, each node within an asset could have a "media" and/or a "direction" + # attribute to tell the browser to take preferably the css resource + # in the relevant viewport or text direction + # + # with the new ir_assert mechanism, these attributes are only evaluated at the t-call-asset + # step, that is, a step earlier than before, implicating a more restrictive usage + # + def test_19_css_specific_attrs_in_tcallassets(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irasset2', + 'glob': 'http://external.css/externalstyle.css', + }) + self.env['ir.asset'].create({ + 'name': '2', + 'bundle': 'test_assetsbundle.irasset2', + 'glob': 'test_assetsbundle/static/src/css/test_cssfile1.css', + }) + view = self.make_asset_view('test_assetsbundle.irasset2', { + 't-js': 'false', + 't-css': 'true', + 'media': 'print', + }) + + rendered = view._render() + html_tree = lxml.etree.fromstring(rendered) + stylesheets = html_tree.findall('link') + self.assertEqual(len(stylesheets), 2) + self.assertEqual(stylesheets[0].get('href'), 'http://external.css/externalstyle.css') + self.assertEqual(stylesheets[0].get('media'), 'print') + + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irasset2')], order='create_date DESC', limit=1) + self.assertEqual(len(attach), 1) + + def test_20_css_base(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irasset2', + 'glob': 'http://external.css/externalstyle.css', + }) + self.env['ir.asset'].create({ + 'name': '2', + 'bundle': 'test_assetsbundle.irasset2', + 'glob': 'test_assetsbundle/static/src/scss/test_file1.scss', + }) + view = self.make_asset_view('test_assetsbundle.irasset2', { + 't-js': 'false', + 't-css': 'true', + }) + + rendered = view._render() + html_tree = lxml.etree.fromstring(rendered) + stylesheets = html_tree.findall('link') + self.assertEqual(len(stylesheets), 2) + self.assertEqual(stylesheets[0].get('href'), 'http://external.css/externalstyle.css') + for css in stylesheets: + self.assertFalse(css.get('media')) + + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irasset2')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/scss/test_file1.scss defined in bundle 'test_assetsbundle.irasset2' */ + .rule1{color: black;} + ''' + ) + + def test_21_js_before_css(self): + '''Non existing target node: ignore the manifest line''' + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_other.bundle4': [ + ('before', 'test_assetsbundle/static/src/css/test_cssfile1.css', '/test_assetsbundle/static/src/js/test_jsfile4.js') + ] + } + } + view = self.make_asset_view('test_assetsbundle.bundle4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.bundle4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.bundle4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.bundle4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.bundle4' */ + var c=3; + ''' + ) + + def test_22_js_before_js(self): + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_assetsbundle.bundle4': [ + ('before', '/test_assetsbundle/static/src/js/test_jsfile3.js', '/test_assetsbundle/static/src/js/test_jsfile4.js') + ] + } + } + view = self.make_asset_view('test_assetsbundle.bundle4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.bundle4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.bundle4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.bundle4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.bundle4' */ + var d=4;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.bundle4' */ + var c=3; + ''' + ) + + def test_23_js_after_css(self): + '''Non existing target node: ignore the manifest line''' + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_other.bundle4': [ + ('after', 'test_assetsbundle/static/src/css/test_cssfile1.css', '/test_assetsbundle/static/src/js/test_jsfile4.js') + ] + } + } + view = self.make_asset_view('test_assetsbundle.bundle4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.bundle4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.bundle4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.bundle4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.bundle4' */ + var c=3; + ''' + ) + + def test_24_js_after_js(self): + self.installed_modules.append('test_other') + self.manifests['test_other'] = { + 'name': 'test_other', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + 'assets': { + 'test_assetsbundle.bundle4': [ + ('after', '/test_assetsbundle/static/src/js/test_jsfile2.js', '/test_assetsbundle/static/src/js/test_jsfile4.js') + ] + } + } + view = self.make_asset_view('test_assetsbundle.bundle4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.bundle4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.bundle4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.bundle4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.bundle4' */ + var d=4;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.bundle4' */ + var c=3; + ''' + ) + + def test_25_js_before_js_in_irasset(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.bundle4', + 'glob': '/test_assetsbundle/static/src/js/test_jsfile4.js', + 'target': '/test_assetsbundle/static/src/js/test_jsfile3.js', + 'directive': 'before', + }) + view = self.make_asset_view('test_assetsbundle.bundle4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.bundle4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.bundle4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.bundle4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.bundle4' */ + var d=4;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.bundle4' */ + var c=3; + ''' + ) + + def test_26_js_after_js_in_irasset(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.bundle4', + 'glob': '/test_assetsbundle/static/src/js/test_jsfile4.js', + 'target': '/test_assetsbundle/static/src/js/test_jsfile2.js', + 'directive': 'after', + }) + view = self.make_asset_view('test_assetsbundle.bundle4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.bundle4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.bundle4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.bundle4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.bundle4' */ + var d=4;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.bundle4' */ + var c=3; + ''' + ) + + def test_27_mixing_after_before_js_css_in_irasset(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.bundle4', + 'glob': '/test_assetsbundle/static/src/js/test_jsfile4.js', + 'target': '/test_assetsbundle/static/src/css/test_cssfile1.css', + 'directive': 'after', + }) + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.bundle4', + 'glob': '/test_assetsbundle/static/src/css/test_cssfile3.css', + 'target': '/test_assetsbundle/static/src/js/test_jsfile2.js', + 'directive': 'before', + }) + view = self.make_asset_view('test_assetsbundle.bundle4', { + 't-js': 'true', + 't-css': 'true', + }) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.bundle4')], order='create_date DESC', limit=2) + attach_css = None + attach_js = None + for a in attach: + if '.css' in a.url: + attach_css = a + elif '.js' in a.url: + attach_js = a + + js_content = attach_js.raw.decode() + self.assertStringEqual( + js_content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.bundle4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.bundle4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.bundle4' */ + var d=4;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.bundle4' */ + var c=3; + ''' + ) + + css_content = attach_css.raw.decode() + self.assertStringEqual( + css_content, + ''' + /* /test_assetsbundle/static/src/css/test_cssfile3.css defined in bundle 'test_assetsbundle.bundle4' */ + .rule4{color: green;} + + /* /test_assetsbundle/static/src/css/test_cssfile1.css defined in bundle 'test_assetsbundle.bundle4' */ + .rule1{color: black;}.rule2{color: yellow;}.rule3{color: red;} + + /* /test_assetsbundle/static/src/css/test_cssfile2.css defined in bundle 'test_assetsbundle.bundle4' */ + .rule4{color: blue;} + ''' + ) + + def test_28_js_after_js_in_irasset_wrong_path(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.wrong_path', + 'glob': '/test_assetsbundle/static/src/js/test_jsfile4.js', + }) + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.wrong_path', + 'glob': '/test_assetsbundle/static/src/js/test_jsfile1.js', + 'target': '/test_assetsbundle/static/src/js/doesnt_exist.js', + 'directive': 'after', + }) + view = self.make_asset_view('test_assetsbundle.wrong_path') + with self.assertRaises(Exception) as cm: + view._render() + self.assertTrue( + "test_assetsbundle/static/src/js/doesnt_exist.js not found" in cm.exception.message + ) + + def test_29_js_after_js_in_irasset_glob(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.manifest4', + 'glob': '/test_assetsbundle/static/src/*/**', + 'target': '/test_assetsbundle/static/src/js/test_jsfile3.js', + 'directive': 'after', + }) + view = self.make_asset_view('test_assetsbundle.manifest4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest4' */ + var c=3;; + + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.manifest4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.manifest4' */ + var d=4; + ''' + ) + + def test_30_js_before_js_in_irasset_glob(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.manifest4', + 'glob': '/test_assetsbundle/static/src/js/test_jsfile[124].js', + 'target': '/test_assetsbundle/static/src/js/test_jsfile3.js', + 'directive': 'before', + }) + view = self.make_asset_view('test_assetsbundle.manifest4') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest4')], order='create_date DESC', limit=1) + content = attach.raw.decode() + self.assertStringEqual( + content, + ''' + /* /test_assetsbundle/static/src/js/test_jsfile1.js defined in bundle 'test_assetsbundle.manifest4' */ + var a=1;; + + /* /test_assetsbundle/static/src/js/test_jsfile2.js defined in bundle 'test_assetsbundle.manifest4' */ + var b=2;; + + /* /test_assetsbundle/static/src/js/test_jsfile4.js defined in bundle 'test_assetsbundle.manifest4' */ + var d=4;; + + /* /test_assetsbundle/static/src/js/test_jsfile3.js defined in bundle 'test_assetsbundle.manifest4' */ + var c=3; + ''' + ) + + @mute_logger('odoo.addons.base.models.ir_asset') + def test_31(self): + path_to_dummy = '../../tests/dummy.js' + me = pathlib.Path(__file__).parent.absolute() + file_path = me.joinpath("..", path_to_dummy) # assuming me = test_assetsbundle/tests + self.assertTrue(os.path.isfile(file_path)) + + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irassetsec', + 'glob': '/test_assetsbundle/%s' % path_to_dummy, + }) + view = self.make_asset_view('test_assetsbundle.irassetsec') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irassetsec')], order='create_date DESC', limit=1) + self.assertFalse(attach.exists()) + + @mute_logger('odoo.addons.base.models.ir_asset') + def test_32(self): + path_to_dummy = '../../tests/dummy.xml' + me = pathlib.Path(__file__).parent.absolute() + file_path = me.joinpath("..", path_to_dummy) # assuming me = test_assetsbundle/tests + self.assertTrue(os.path.isfile(file_path)) + + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irassetsec', + 'glob': '/test_assetsbundle/%s' % path_to_dummy, + }) + + files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=self.installed_modules, xml=False) + self.assertFalse(files) + + def test_33(self): + self.manifests['notinstalled_module'] = { + 'name': 'notinstalled_module', + 'depends': ['test_assetsbundle'], + 'addons_path': pathlib.Path(__file__).resolve().parent, + } + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irassetsec', + 'glob': '/notinstalled_module/somejsfile.js', + }) + view = self.make_asset_view('test_assetsbundle.irassetsec') + with self.assertRaises(QWebException) as cm: + view._render() + + self.assertTrue('Unallowed to fetch files from addon notinstalled_module' in cm.exception.message) + + def test_33bis_notinstalled_not_in_manifests(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irassetsec', + 'glob': '/notinstalled_module/somejsfile.js', + }) + self.make_asset_view('test_assetsbundle.irassetsec') + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irassetsec')], order='create_date DESC', limit=1) + self.assertFalse(attach.exists()) + + @mute_logger('odoo.addons.base.models.ir_asset') + def test_34(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irassetsec', + 'glob': '/test_assetsbundle/__manifest__.py', + }) + view = self.make_asset_view('test_assetsbundle.irassetsec') + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irassetsec')], order='create_date DESC', limit=1) + self.assertFalse(attach.exists()) + + @mute_logger('odoo.addons.base.models.ir_asset') + def test_35(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irassetsec', + 'glob': '/test_assetsbundle/data/ir_asset.xml', + }) + files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=self.installed_modules, xml=False) + self.assertFalse(files) + + def test_36(self): + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irassetsec', + 'glob': '/test_assetsbundle/static/accessible.xml', + }) + files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=self.installed_modules, xml=False) + self.assertEqual(len(files), 1) + self.assertTrue('test_assetsbundle/static/accessible.xml' in files[0][0]) + + def test_37_path_can_be_an_attachment(self): + scss_code = base64.b64encode(b""" + .my_div { + &.subdiv { + color: blue; + } + } + """) + self.env['ir.attachment'].create({ + 'name': 'my custom scss', + 'mimetype': 'text/scss', + 'type': 'binary', + 'url': 'test_assetsbundle/my_style_attach.scss', + 'datas': scss_code + }) + + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irasset_custom_attach', + 'glob': 'test_assetsbundle/my_style_attach.scss', + }) + view = self.make_asset_view('test_assetsbundle.irasset_custom_attach', {'t-css': True}) + view._render() + attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irasset_custom_attach')], order='create_date DESC', limit=1) + content = attach.raw.decode() + # The scss should be compiled + self.assertStringEqual( + content, + """ + /* test_assetsbundle/my_style_attach.scss defined in bundle 'test_assetsbundle.irasset_custom_attach' */ + .my_div.subdiv{color: blue;} + """ + ) diff --git a/odoo/addons/test_assetsbundle/views/views.xml b/odoo/addons/test_assetsbundle/views/views.xml index 5302ee6080f..ffd6471d5b2 100644 --- a/odoo/addons/test_assetsbundle/views/views.xml +++ b/odoo/addons/test_assetsbundle/views/views.xml @@ -1,10 +1,4 @@ - - - - - - - -