[IMP] *: Improve assets management
This commit changes the way assets are declared in Odoo modules. Before: assets were declared in template files. Template bundles were generated from primary templates, so technically any qweb template could have been called as an asset bundle, with the 't-call-assets' directive. Being standard qweb templates, they had access to standard HTML tags (script, link, with or without raw scripts or style definition), qweb directives (t-call, t-raw, etc.) and could be inherited by other templates. Now: assets are defined in the module's manifest and generated by the 't-call-assets' directive. More information on the new system can be found on the updated user documentation (see the "JavaScript Reference" section). Task: 2352566 Co-authored-by: Bruno Boi <boi@odoo.com> Co-authored-by: Julien Mougenot <jum@odoo.com> Co-authored-by: Lucas Perais <lpe@odoo.com> Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com> Co-authored-by: Raphael Collet <rco@odoo.com> Co-authored-by: Simon Genin <ges@odoo.com>
This commit is contained in:
co-authored by
Bruno Boi
Lucas Perais
Mathieu Duckerts-Antoine
Raphael Collet
Simon Genin
parent
ee412d2138
commit
8cc066173d
@@ -29,8 +29,8 @@ odoo.define('bus.WebClient', function (require) {
|
||||
*/
|
||||
show_application() {
|
||||
const shown = this._super(...arguments);
|
||||
document.querySelectorAll('*[data-asset-xmlid]').forEach(el => {
|
||||
this._assets[el.getAttribute('data-asset-xmlid')] = el.getAttribute('data-asset-version');
|
||||
document.querySelectorAll('*[data-asset-bundle]').forEach(el => {
|
||||
this._assets[el.getAttribute('data-asset-bundle')] = el.getAttribute('data-asset-version');
|
||||
});
|
||||
this.call('bus_service', 'onNotification', this, this._onNotification);
|
||||
this.call('bus_service', 'addChannel', 'bundle_changed');
|
||||
|
||||
+38
-100
@@ -33,14 +33,14 @@ import unicodedata
|
||||
import odoo
|
||||
import odoo.modules.registry
|
||||
from odoo.api import call_kw, Environment
|
||||
from odoo.modules import get_module_path, get_resource_path
|
||||
from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property, float_repr, osutil
|
||||
from odoo.modules import get_module_path, get_resource_path, module
|
||||
from odoo.tools import image_process, html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property, float_repr, osutil
|
||||
from odoo.tools.mimetypes import guess_mimetype
|
||||
from odoo.tools.translate import _
|
||||
from odoo.tools.misc import str2bool, xlsxwriter, file_open
|
||||
from odoo.tools.safe_eval import safe_eval, time
|
||||
from odoo import http, tools
|
||||
from odoo.http import content_disposition, dispatch_rpc, request, serialize_exception as _serialize_exception, Response
|
||||
from odoo.http import content_disposition, dispatch_rpc, request, serialize_exception as _serialize_exception
|
||||
from odoo.exceptions import AccessError, UserError, AccessDenied
|
||||
from odoo.models import check_method_name
|
||||
from odoo.service import db, security
|
||||
@@ -183,84 +183,10 @@ def ensure_db(redirect='/web/database/selector'):
|
||||
|
||||
request.session.db = db
|
||||
|
||||
def module_installed(environment):
|
||||
# Candidates module the current heuristic is the /static dir
|
||||
loadable = list(http.addons_manifest)
|
||||
|
||||
# Retrieve database installed modules
|
||||
# TODO The following code should move to ir.module.module.list_installed_modules()
|
||||
Modules = environment['ir.module.module']
|
||||
domain = [('state','=','installed'), ('name','in', loadable)]
|
||||
modules = OrderedDict(
|
||||
(module.name, module.dependencies_id.mapped('name'))
|
||||
for module in Modules.search(domain)
|
||||
)
|
||||
|
||||
sorted_modules = topological_sort(modules)
|
||||
return sorted_modules
|
||||
|
||||
def module_installed_bypass_session(dbname):
|
||||
try:
|
||||
registry = odoo.registry(dbname)
|
||||
with registry.cursor() as cr:
|
||||
return module_installed(
|
||||
environment=Environment(cr, odoo.SUPERUSER_ID, {}))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def module_boot(db=None):
|
||||
server_wide_modules = odoo.conf.server_wide_modules or []
|
||||
serverside = ['base', 'web']
|
||||
dbside = []
|
||||
for i in server_wide_modules:
|
||||
if i in http.addons_manifest and i not in serverside:
|
||||
serverside.append(i)
|
||||
monodb = db or db_monodb()
|
||||
if monodb:
|
||||
dbside = module_installed_bypass_session(monodb)
|
||||
dbside = [i for i in dbside if i not in serverside]
|
||||
addons = serverside + dbside
|
||||
return addons
|
||||
|
||||
|
||||
def fs2web(path):
|
||||
"""convert FS path into web path"""
|
||||
return '/'.join(path.split(os.path.sep))
|
||||
|
||||
def manifest_glob(extension, addons=None, db=None, include_remotes=False):
|
||||
if addons is None:
|
||||
addons = module_boot(db=db)
|
||||
|
||||
r = []
|
||||
for addon in addons:
|
||||
manifest = http.addons_manifest.get(addon, None)
|
||||
if not manifest:
|
||||
continue
|
||||
# ensure does not ends with /
|
||||
addons_path = os.path.join(manifest['addons_path'], '')[:-1]
|
||||
globlist = manifest.get(extension, [])
|
||||
for pattern in globlist:
|
||||
if pattern.startswith(('http://', 'https://', '//')):
|
||||
if include_remotes:
|
||||
r.append((None, pattern, addon))
|
||||
else:
|
||||
for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
|
||||
r.append((path, fs2web(path[len(addons_path):]), addon))
|
||||
return r
|
||||
|
||||
|
||||
def manifest_list(extension, mods=None, db=None, debug=None):
|
||||
""" list resources to load specifying either:
|
||||
mods: a comma separated string listing modules
|
||||
db: a database name (return all installed modules in that database)
|
||||
"""
|
||||
if debug is not None:
|
||||
_logger.warning("odoo.addons.web.main.manifest_list(): debug parameter is deprecated")
|
||||
mods = mods.split(',')
|
||||
files = manifest_glob(extension, addons=mods, db=db, include_remotes=True)
|
||||
return [wp for _fp, wp, addon in files]
|
||||
|
||||
def get_last_modified(files):
|
||||
""" Returns the modification time of the most recently modified
|
||||
file provided
|
||||
@@ -584,18 +510,13 @@ class HomeStaticTemplateHelpers(object):
|
||||
if re.match(COMMENT_PATTERN, comment.text.strip()):
|
||||
comment.getparent().remove(comment)
|
||||
|
||||
def _manifest_glob(self):
|
||||
'''Proxy for manifest_glob
|
||||
Usefull to make 'self' testable'''
|
||||
return manifest_glob('qweb', self.addons, self.db)
|
||||
|
||||
def _read_addon_file(self, file_path):
|
||||
"""Reads the content of a file given by file_path
|
||||
Usefull to make 'self' testable
|
||||
:param str file_path:
|
||||
:returns: str
|
||||
"""
|
||||
with open(file_path, 'rb') as fp:
|
||||
with file_open(file_path, 'rb') as fp:
|
||||
contents = fp.read()
|
||||
return contents
|
||||
|
||||
@@ -621,7 +542,7 @@ class HomeStaticTemplateHelpers(object):
|
||||
xml = self._compute_xml_tree(addon, fname, contents)
|
||||
|
||||
if root is None:
|
||||
root = etree.Element(xml.tag)
|
||||
root = etree.Element('templates')
|
||||
|
||||
for addon in self.template_dict.values():
|
||||
for template in addon.values():
|
||||
@@ -629,22 +550,34 @@ class HomeStaticTemplateHelpers(object):
|
||||
|
||||
return etree.tostring(root, encoding='utf-8') if root is not None else b'', checksum.hexdigest()[:64]
|
||||
|
||||
def _get_asset_paths(self):
|
||||
"""Proxy for ir_asset._get_asset_paths
|
||||
Useful to make 'self' testable.
|
||||
"""
|
||||
return request.env['ir.asset']._get_asset_paths(addons=self.addons, bundle='web.assets_qweb', xml=True)
|
||||
|
||||
def _get_qweb_templates(self):
|
||||
"""One and only entry point that gets and evaluates static qweb templates
|
||||
|
||||
:rtype: (str, str)
|
||||
"""
|
||||
files = OrderedDict([(addon, list()) for addon in self.addons])
|
||||
[files[f[2]].append(f[0]) for f in self._manifest_glob()]
|
||||
content, checksum = self._concat_xml(files)
|
||||
xml_paths = defaultdict(list)
|
||||
|
||||
# group paths by module, keeping them in order
|
||||
for path, addon, _ in self._get_asset_paths():
|
||||
addon_paths = xml_paths[addon]
|
||||
if path not in addon_paths:
|
||||
addon_paths.append(path)
|
||||
|
||||
content, checksum = self._concat_xml(xml_paths)
|
||||
return content, checksum
|
||||
|
||||
@classmethod
|
||||
def get_qweb_templates_checksum(cls, addons, db=None, debug=False):
|
||||
def get_qweb_templates_checksum(cls, addons=None, db=None, debug=False):
|
||||
return cls(addons, db, checksum_only=True, debug=debug)._get_qweb_templates()[1]
|
||||
|
||||
@classmethod
|
||||
def get_qweb_templates(cls, addons, db=None, debug=False):
|
||||
def get_qweb_templates(cls, addons=None, db=None, debug=False):
|
||||
return cls(addons, db, debug=debug)._get_qweb_templates()[0]
|
||||
|
||||
|
||||
@@ -979,14 +912,6 @@ class Home(http.Controller):
|
||||
|
||||
class WebClient(http.Controller):
|
||||
|
||||
@http.route('/web/webclient/csslist', type='json', auth="none")
|
||||
def csslist(self, mods=None):
|
||||
return manifest_list('css', mods=mods)
|
||||
|
||||
@http.route('/web/webclient/jslist', type='json', auth="none")
|
||||
def jslist(self, mods=None):
|
||||
return manifest_list('js', mods=mods)
|
||||
|
||||
@http.route('/web/webclient/locale/<string:lang>', type='http', auth="none")
|
||||
def load_locale(self, lang):
|
||||
magic_file_finding = [lang.replace("_", '-').lower(), lang.split('_')[0]]
|
||||
@@ -1011,6 +936,10 @@ class WebClient(http.Controller):
|
||||
|
||||
@http.route('/web/webclient/qweb/<string:unique>', type='http', auth="none", cors="*")
|
||||
def qweb(self, unique, mods=None, db=None):
|
||||
|
||||
if not request.db and mods is None:
|
||||
mods = odoo.conf.server_wide_modules or []
|
||||
|
||||
content = HomeStaticTemplateHelpers.get_qweb_templates(mods, db, debug=request.session.debug)
|
||||
|
||||
return request.make_response(content, [
|
||||
@@ -1019,7 +948,7 @@ class WebClient(http.Controller):
|
||||
])
|
||||
|
||||
@http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
|
||||
def bootstrap_translations(self, mods):
|
||||
def bootstrap_translations(self, mods=None):
|
||||
""" Load local translations from *.po files, as a temporary solution
|
||||
until we have established a valid session. This is meant only
|
||||
for translating the login page and db management chrome, using
|
||||
@@ -1031,9 +960,15 @@ class WebClient(http.Controller):
|
||||
request.session._fix_lang(context)
|
||||
lang = context['lang'].split('_')[0]
|
||||
|
||||
if mods is None:
|
||||
mods = odoo.conf.server_wide_modules or []
|
||||
if request.db:
|
||||
mods = request.env.registry._init_modules | set(mods)
|
||||
|
||||
translations_per_module = {}
|
||||
for addon_name in mods:
|
||||
if http.addons_manifest[addon_name].get('bootstrap'):
|
||||
manifest = http.addons_manifest.get(addon_name)
|
||||
if manifest and manifest.get('bootstrap'):
|
||||
addons_path = http.addons_manifest[addon_name]['addons_path']
|
||||
f_name = os.path.join(addons_path, addon_name, "i18n", lang + ".po")
|
||||
if not os.path.exists(f_name):
|
||||
@@ -1057,6 +992,9 @@ class WebClient(http.Controller):
|
||||
|
||||
if mods:
|
||||
mods = mods.split(',')
|
||||
elif mods is None:
|
||||
mods = list(request.env.registry._init_modules) + (odoo.conf.server_wide_modules or [])
|
||||
|
||||
translations_per_module, lang_params = request.env["ir.translation"].get_translations_for_webclient(mods, lang)
|
||||
|
||||
body = json.dumps({
|
||||
@@ -1285,7 +1223,7 @@ class Session(http.Controller):
|
||||
@http.route('/web/session/modules', type='json', auth="user")
|
||||
def modules(self):
|
||||
# return all installed modules. Web client is smart enough to not load a module twice
|
||||
return module_installed(environment=request.env(user=odoo.SUPERUSER_ID))
|
||||
return request.env.registry._init_modules | set([module.current_test] if module.current_test else [])
|
||||
|
||||
@http.route('/web/session/save_session_action', type='json', auth="user")
|
||||
def save_session_action(self, the_action):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -51,9 +51,9 @@ class TestStaticInheritanceCommon(BaseCase):
|
||||
def setUp(self):
|
||||
super(TestStaticInheritanceCommon, self).setUp()
|
||||
# output is "manifest_glob" return
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
('module_2_file_1', None, 'module_2'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
('module_2_file_1', 'module_2', 'bundle_1'),
|
||||
]
|
||||
|
||||
self.template_files = {
|
||||
@@ -111,18 +111,18 @@ class TestStaticInheritanceCommon(BaseCase):
|
||||
|
||||
# Private methods
|
||||
def _get_module_names(self):
|
||||
return ','.join([glob[2] for glob in self.modules])
|
||||
return ','.join([glob[1] for glob in self.asset_paths])
|
||||
|
||||
def _set_patchers(self):
|
||||
def _patched_for_manifest_glob(*args, **kwargs):
|
||||
def _patched_for_get_asset_paths(*args, **kwargs):
|
||||
# Ordered by module
|
||||
return self.modules
|
||||
return self.asset_paths
|
||||
|
||||
def _patch_for_read_addon_file(*args, **kwargs):
|
||||
return self.template_files[args[1]]
|
||||
|
||||
self.patchers = [
|
||||
patch.object(HomeStaticTemplateHelpers, '_manifest_glob', _patched_for_manifest_glob),
|
||||
patch.object(HomeStaticTemplateHelpers, '_get_asset_paths', _patched_for_get_asset_paths),
|
||||
patch.object(HomeStaticTemplateHelpers, '_read_addon_file', _patch_for_read_addon_file),
|
||||
]
|
||||
|
||||
@@ -178,8 +178,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
</templates>
|
||||
'''
|
||||
}
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
|
||||
expected = b"""
|
||||
@@ -218,8 +218,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
</templates>
|
||||
'''
|
||||
}
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
contents = HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
|
||||
expected = b"""
|
||||
@@ -243,9 +243,9 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_static_inheritance_in_same_module(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
('module_1_file_2', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
('module_1_file_2', 'module_1', 'bundle_1'),
|
||||
]
|
||||
|
||||
self.template_files = {
|
||||
@@ -286,8 +286,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_static_inheritance_in_same_file(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
|
||||
self.template_files = {
|
||||
@@ -323,8 +323,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_static_inherit_extended_template(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b'''
|
||||
@@ -367,10 +367,10 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_sibling_extension(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
('module_2_file_1', None, 'module_2'),
|
||||
('module_3_file_1', None, 'module_3'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
('module_2_file_1', 'module_2', 'bundle_1'),
|
||||
('module_3_file_1', 'module_3', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b'''
|
||||
@@ -420,7 +420,7 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_static_misordered_modules(self):
|
||||
self.modules.reverse()
|
||||
self.asset_paths.reverse()
|
||||
with self.assertRaises(ValueError) as ve:
|
||||
HomeStaticTemplateHelpers.get_qweb_templates(addons=self._get_module_names(), debug=True)
|
||||
|
||||
@@ -454,8 +454,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
"""
|
||||
Replacing a template's meta definition in place doesn't keep the original attrs of the template
|
||||
"""
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -484,8 +484,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_replace_in_debug_mode2(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -525,8 +525,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
becomes outside of the template
|
||||
This doesn't mean anything in terms of the business of template inheritance
|
||||
But it is in the XPATH specs"""
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -565,8 +565,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
"""
|
||||
Root node IS targeted by //NODE_TAG in xpath
|
||||
"""
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -603,8 +603,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
Root node IS targeted by //NODE_TAG in xpath
|
||||
"""
|
||||
self.maxDiff = None
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -642,8 +642,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
The inheriting template has got both its own defining attrs
|
||||
and new ones if one is to replace its defining root node
|
||||
"""
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -680,8 +680,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
|
||||
def test_replace_in_nodebug_mode1(self):
|
||||
"""Comments already in the arch are ignored"""
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -717,8 +717,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_inherit_from_dotted_tname_1(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -754,8 +754,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_inherit_from_dotted_tname_2(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -791,8 +791,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_inherit_from_dotted_tname_2bis(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -828,8 +828,8 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_inherit_from_dotted_tname_2ter(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -865,9 +865,9 @@ class TestStaticInheritance(TestStaticInheritanceCommon):
|
||||
self.assertXMLEqual(contents, expected)
|
||||
|
||||
def test_inherit_from_dotted_tname_3(self):
|
||||
self.modules = [
|
||||
('module_1_file_1', None, 'module_1'),
|
||||
('module_2_file_1', None, 'module_2'),
|
||||
self.asset_paths = [
|
||||
('module_1_file_1', 'module_1', 'bundle_1'),
|
||||
('module_2_file_1', 'module_2', 'bundle_1'),
|
||||
]
|
||||
self.template_files = {
|
||||
'module_1_file_1': b"""
|
||||
@@ -916,14 +916,14 @@ class TestStaticInheritancePerformance(TestStaticInheritanceCommon):
|
||||
nMod modules
|
||||
each module: has nFilesPerModule files, each of which contains nTemplatePerFile templates
|
||||
"""
|
||||
self.modules = []
|
||||
self.asset_paths = []
|
||||
self.template_files = {}
|
||||
number_templates = 0
|
||||
for m in range(nMod):
|
||||
for f in range(nFilePerMod):
|
||||
mname = 'mod_%s' % m
|
||||
fname = 'mod_%s_file_%s' % (m, f)
|
||||
self.modules.append((fname, None, mname))
|
||||
self.asset_paths.append((fname, mname, 'bundle_1'))
|
||||
|
||||
_file = '<templates id="template" xml:space="preserve">'
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
<meta name="viewport" content="initial-scale=1"/>
|
||||
<title><t t-esc="title or 'Odoo Report'"/></title>
|
||||
<t t-call-assets="web.report_assets_common" t-js="false"/>
|
||||
<!--The following is kind of a hack-->
|
||||
<!--in an iframe, we don't want the session to perform qweb_templates rpc-->
|
||||
<script type="text/javascript">
|
||||
window.odoo = {};
|
||||
window.odoo.session_info = {is_report: true};
|
||||
</script>
|
||||
<t t-call-assets="web.assets_common" t-css="false"/>
|
||||
<t t-call-assets="web.report_assets_common" t-css="false"/>
|
||||
</head>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -124,25 +124,25 @@ class Assets(models.AbstractModel):
|
||||
parts = url.rsplit(".", 1)
|
||||
return "%s.custom.%s.%s" % (parts[0], bundle_xmlid, parts[1])
|
||||
|
||||
def reset_asset(self, url, bundle_xmlid):
|
||||
def reset_asset(self, url, bundle):
|
||||
"""
|
||||
Delete the potential customizations made to a given (original) asset.
|
||||
|
||||
Params:
|
||||
url (str): the URL of the original asset (scss / js) file
|
||||
|
||||
bundle_xmlid (str):
|
||||
bundle (str):
|
||||
the name of the bundle in which the customizations to delete
|
||||
were made
|
||||
"""
|
||||
custom_url = self.make_custom_asset_file_url(url, bundle_xmlid)
|
||||
custom_url = self.make_custom_asset_file_url(url, bundle)
|
||||
|
||||
# Simply delete the attachement which contains the modified scss/js file
|
||||
# and the xpath view which links it
|
||||
self._get_custom_attachment(custom_url).unlink()
|
||||
self._get_custom_view(custom_url).unlink()
|
||||
self._get_custom_asset(custom_url).unlink()
|
||||
|
||||
def save_asset(self, url, bundle_xmlid, content, file_type):
|
||||
def save_asset(self, url, bundle, content, file_type):
|
||||
"""
|
||||
Customize the content of a given asset (scss / js).
|
||||
|
||||
@@ -151,7 +151,7 @@ class Assets(models.AbstractModel):
|
||||
the URL of the original asset to customize (whether or not the
|
||||
asset was already customized)
|
||||
|
||||
bundle_xmlid (src):
|
||||
bundle (src):
|
||||
the name of the bundle in which the customizations will take
|
||||
effect
|
||||
|
||||
@@ -160,7 +160,7 @@ class Assets(models.AbstractModel):
|
||||
file_type (src):
|
||||
either 'scss' or 'js' according to the file being customized
|
||||
"""
|
||||
custom_url = self.make_custom_asset_file_url(url, bundle_xmlid)
|
||||
custom_url = self.make_custom_asset_file_url(url, bundle)
|
||||
datas = base64.b64encode((content or "\n").encode("utf-8"))
|
||||
|
||||
# Check if the file to save had already been modified
|
||||
@@ -179,49 +179,27 @@ class Assets(models.AbstractModel):
|
||||
'datas': datas,
|
||||
'url': custom_url,
|
||||
}
|
||||
new_attach.update(self._save_asset_attachment_hook())
|
||||
new_attach.update(self._save_asset_hook())
|
||||
self.env["ir.attachment"].create(new_attach)
|
||||
|
||||
# Create a view to extend the template which adds the original file
|
||||
# to link the new modified version instead
|
||||
file_type_info = {
|
||||
'tag': 'link' if file_type == 'scss' else 'script',
|
||||
'attribute': 'href' if file_type == 'scss' else 'src',
|
||||
# Create an asset with the new attachment
|
||||
IrAsset = self.env['ir.asset']
|
||||
new_asset = {
|
||||
'glob': custom_url,
|
||||
'target': url,
|
||||
'directive': 'replace',
|
||||
**self._save_asset_hook(),
|
||||
}
|
||||
|
||||
def views_linking_url(view):
|
||||
"""
|
||||
Returns whether the view arch has some html tag linked to
|
||||
the url. (note: searching for the URL string is not enough as it
|
||||
could appear in a comment or an xpath expression.)
|
||||
"""
|
||||
tree = etree.XML(view.arch)
|
||||
return bool(tree.xpath("//%%(tag)s[@%%(attribute)s='%(url)s']" % {
|
||||
'url': url,
|
||||
} % file_type_info))
|
||||
|
||||
IrUiView = self.env["ir.ui.view"]
|
||||
view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(views_linking_url)
|
||||
new_view = {
|
||||
'name': custom_url,
|
||||
'key': 'web_editor.%s_%s' % (file_type, str(uuid.uuid4())[:6]),
|
||||
'mode': "extension",
|
||||
'inherit_id': view_to_xpath.id,
|
||||
'arch': """
|
||||
<data inherit_id="%(inherit_xml_id)s" name="%(name)s">
|
||||
<xpath expr="//%%(tag)s[@%%(attribute)s='%(url_to_replace)s']" position="attributes">
|
||||
<attribute name="%%(attribute)s">%(new_url)s</attribute>
|
||||
</xpath>
|
||||
</data>
|
||||
""" % {
|
||||
'inherit_xml_id': view_to_xpath.xml_id,
|
||||
'name': custom_url,
|
||||
'url_to_replace': url,
|
||||
'new_url': custom_url,
|
||||
} % file_type_info
|
||||
}
|
||||
new_view.update(self._save_asset_view_hook())
|
||||
IrUiView.create(new_view)
|
||||
target_asset = self._get_custom_asset(url)
|
||||
if target_asset:
|
||||
new_asset['name'] = target_asset.name + ' override'
|
||||
new_asset['bundle'] = target_asset.bundle
|
||||
new_asset['sequence'] = target_asset.sequence
|
||||
else:
|
||||
path_parts = '/'.join(os.path.split(custom_url)).split('/')
|
||||
new_asset['name'] = '%s: replace %s' % (bundle, path_parts[-1])
|
||||
new_asset['bundle'] = IrAsset._get_related_bundle(url, bundle)
|
||||
IrAsset.create(new_asset)
|
||||
|
||||
self.env["ir.qweb"].clear_caches()
|
||||
|
||||
@@ -239,35 +217,24 @@ class Assets(models.AbstractModel):
|
||||
assert op in ('in', '='), 'Invalid operator'
|
||||
return self.env["ir.attachment"].search([("url", op, custom_url)])
|
||||
|
||||
def _get_custom_view(self, custom_url, op='='):
|
||||
def _get_custom_asset(self, custom_url):
|
||||
"""
|
||||
Fetch the ir.ui.view record related to the given customized asset (the
|
||||
Fetch the ir.asset record related to the given customized asset (the
|
||||
inheriting view which replace the original asset by the customized one).
|
||||
|
||||
Params:
|
||||
custom_url (str): the URL of the customized asset
|
||||
op (str, default: '='): the operator to use to search the records
|
||||
|
||||
Returns:
|
||||
ir.ui.view()
|
||||
ir.asset()
|
||||
"""
|
||||
assert op in ('='), 'Invalid operator'
|
||||
return self.env["ir.ui.view"].search([("name", op, custom_url)])
|
||||
url = custom_url[1:] if custom_url.startswith(('/', '\\')) else custom_url
|
||||
return self.env['ir.asset'].search([('glob', 'like', url)])
|
||||
|
||||
def _save_asset_attachment_hook(self):
|
||||
def _save_asset_hook(self):
|
||||
"""
|
||||
Returns the additional values to use to write the DB on customized
|
||||
attachment creation.
|
||||
|
||||
Returns:
|
||||
dict
|
||||
"""
|
||||
return {}
|
||||
|
||||
def _save_asset_view_hook(self):
|
||||
"""
|
||||
Returns the additional values to use to write the DB on customized
|
||||
asset's related view creation.
|
||||
attachment and asset creation.
|
||||
|
||||
Returns:
|
||||
dict
|
||||
|
||||
@@ -520,7 +520,7 @@ var ViewEditor = Widget.extend({
|
||||
// Store the URL ungrouped by bundle and use the URL as key (resource ID)
|
||||
var resources = type === 'scss' ? this.scss : this.js;
|
||||
_.each(data, function (bundleInfos) {
|
||||
_.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; });
|
||||
_.each(bundleInfos[1], function (info) { info.bundle = bundleInfos[0]; });
|
||||
_.extend(resources, _.indexBy(bundleInfos[1], 'url'));
|
||||
});
|
||||
}
|
||||
@@ -548,7 +548,7 @@ var ViewEditor = Widget.extend({
|
||||
route: '/web_editor/reset_asset',
|
||||
params: {
|
||||
url: resID,
|
||||
bundle_xmlid: resource.bundle_xmlid,
|
||||
bundle: resource.bundle,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -565,13 +565,13 @@ var ViewEditor = Widget.extend({
|
||||
_saveSCSSorJS: function (session) {
|
||||
var self = this;
|
||||
var sessionIdEndsWithJS = _.string.endsWith(session.id, '.js');
|
||||
var bundleXmlID = sessionIdEndsWithJS ? this.js[session.id].bundle_xmlid : this.scss[session.id].bundle_xmlid;
|
||||
var bundle = sessionIdEndsWithJS ? this.js[session.id].bundle : this.scss[session.id].bundle;
|
||||
var fileType = sessionIdEndsWithJS ? 'js' : 'scss';
|
||||
return self._rpc({
|
||||
route: '/web_editor/save_asset',
|
||||
params: {
|
||||
url: session.id,
|
||||
bundle_xmlid: bundleXmlID,
|
||||
bundle,
|
||||
content: session.text,
|
||||
file_type: fileType,
|
||||
},
|
||||
@@ -806,7 +806,7 @@ var ViewEditor = Widget.extend({
|
||||
function _populateList(sortedData, $list, lettersToRemove) {
|
||||
_.each(sortedData, function (bundleInfos) {
|
||||
var $optgroup = $('<optgroup/>', {
|
||||
label: bundleInfos[0].name,
|
||||
label: bundleInfos[0],
|
||||
}).appendTo($list);
|
||||
_.each(bundleInfos[1], function (dataInfo) {
|
||||
var name = dataInfo.url.substring(_.lastIndexOf(dataInfo.url, '/') + 1, dataInfo.url.length - lettersToRemove);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @odoo-module alias=web_editor.wysiwyg.root.test */
|
||||
import WysiwygRoot from "web_editor.wysiwyg.root";
|
||||
import * as WysiwygRoot from "web_editor.wysiwyg.root";
|
||||
|
||||
WysiwygRoot.include({
|
||||
assetLibs: null // We need to add the asset because tests performed overwrites (Dialog, Unbreakable...)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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']
|
||||
|
||||
@@ -6,9 +6,49 @@ from odoo import api, fields, models
|
||||
from odoo.tools.translate import xml_translate
|
||||
from odoo.modules.module import get_resource_from_path
|
||||
|
||||
from odoo.addons.base.models.ir_asset import AFTER_DIRECTIVE, APPEND_DIRECTIVE, BEFORE_DIRECTIVE, DEFAULT_SEQUENCE, INCLUDE_DIRECTIVE, PREPEND_DIRECTIVE, REMOVE_DIRECTIVE, REPLACE_DIRECTIVE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThemeAsset(models.Model):
|
||||
_name = 'theme.ir.asset'
|
||||
_description = 'Theme Asset'
|
||||
|
||||
key = fields.Char()
|
||||
name = fields.Char(required=True)
|
||||
bundle = fields.Char(required=True)
|
||||
directive = fields.Selection(selection=[
|
||||
(APPEND_DIRECTIVE, 'Append'),
|
||||
(PREPEND_DIRECTIVE, 'Prepend'),
|
||||
(AFTER_DIRECTIVE, 'After'),
|
||||
(BEFORE_DIRECTIVE, 'Before'),
|
||||
(REMOVE_DIRECTIVE, 'Remove'),
|
||||
(REPLACE_DIRECTIVE, 'Replace'),
|
||||
(INCLUDE_DIRECTIVE, 'Include')], default=APPEND_DIRECTIVE)
|
||||
glob = fields.Char(required=True)
|
||||
target = fields.Char()
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=DEFAULT_SEQUENCE, required=True)
|
||||
copy_ids = fields.One2many('ir.asset', 'theme_template_id', 'Assets using a copy of me', copy=False, readonly=True)
|
||||
|
||||
def _convert_to_base_model(self, website, **kwargs):
|
||||
self.ensure_one()
|
||||
new_asset = {
|
||||
'name': self.name,
|
||||
'key': self.key,
|
||||
'bundle': self.bundle,
|
||||
'directive': self.directive,
|
||||
'glob': self.glob,
|
||||
'target': self.target,
|
||||
'active': self.active,
|
||||
'sequence': self.sequence,
|
||||
'website_id': website.id,
|
||||
'theme_template_id': self.id,
|
||||
}
|
||||
return new_asset
|
||||
|
||||
|
||||
class ThemeView(models.Model):
|
||||
_name = 'theme.ir.ui.view'
|
||||
_description = 'Theme UI View'
|
||||
@@ -23,7 +63,7 @@ class ThemeView(models.Model):
|
||||
name = fields.Char(required=True)
|
||||
key = fields.Char()
|
||||
type = fields.Char()
|
||||
priority = fields.Integer(default=16, required=True)
|
||||
priority = fields.Integer(default=DEFAULT_SEQUENCE, required=True)
|
||||
mode = fields.Selection([('primary', "Base view"), ('extension', "Extension View")])
|
||||
active = fields.Boolean(default=True)
|
||||
arch = fields.Text(translate=xml_translate)
|
||||
@@ -181,7 +221,8 @@ class Theme(models.AbstractModel):
|
||||
)
|
||||
|
||||
# Reinitialize effets
|
||||
self.disable_view('website.option_ripple_effect')
|
||||
self.disable_asset('website.ripple_effect_scss')
|
||||
self.disable_asset('website.ripple_effect_js')
|
||||
|
||||
# Reinitialize header templates
|
||||
self.enable_view('website.template_header_default')
|
||||
@@ -210,6 +251,24 @@ class Theme(models.AbstractModel):
|
||||
# Reinitialize footer scrolltop template
|
||||
self.disable_view('website.option_footer_scrolltop')
|
||||
|
||||
@api.model
|
||||
def _toggle_asset(self, name, active):
|
||||
ThemeAsset = self.env['theme.ir.asset'].sudo().with_context(active_test=False)
|
||||
obj = ThemeAsset.search([('name', '=', name)])
|
||||
website = self.env['website'].get_current_website()
|
||||
if obj:
|
||||
obj = obj.copy_ids.filtered(lambda x: x.website_id == website)
|
||||
else:
|
||||
Asset = self.env['ir.asset'].sudo().with_context(active_test=False)
|
||||
obj = Asset.search([('name', '=', name)])
|
||||
has_specific = obj.key and Asset.search_count([
|
||||
('key', '=', obj.key),
|
||||
('website_id', '=', website.id)
|
||||
]) >= 1
|
||||
if not has_specific and active == obj.active:
|
||||
return
|
||||
obj.write({'active': active})
|
||||
|
||||
@api.model
|
||||
def _toggle_view(self, xml_id, active):
|
||||
obj = self.env.ref(xml_id)
|
||||
@@ -232,6 +291,14 @@ class Theme(models.AbstractModel):
|
||||
return
|
||||
obj.write({'active': active})
|
||||
|
||||
@api.model
|
||||
def enable_asset(self, name):
|
||||
self._toggle_asset(name, True)
|
||||
|
||||
@api.model
|
||||
def disable_asset(self, name):
|
||||
self._toggle_asset(name, False)
|
||||
|
||||
@api.model
|
||||
def enable_view(self, xml_id):
|
||||
self._toggle_view(xml_id, True)
|
||||
@@ -271,6 +338,11 @@ class IrUiView(models.Model):
|
||||
res &= super(IrUiView, no_arch_updated_views).write(vals)
|
||||
return res
|
||||
|
||||
class IrAsset(models.Model):
|
||||
_inherit = 'ir.asset'
|
||||
|
||||
theme_template_id = fields.Many2one('theme.ir.asset', copy=False)
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = 'ir.attachment'
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ access_website_track_designer,access_website_track_designer,model_website_track,
|
||||
access_website_track_system,access_website_track_system,model_website_track,base.group_system,1,1,1,1
|
||||
access_website_route_designer,access_website_designer_route,model_website_route,group_website_designer,1,1,1,1
|
||||
access_theme_ir_ui_view,access_theme_ir_ui_view,model_theme_ir_ui_view,base.group_system,1,1,1,1
|
||||
access_theme_ir_asset_group_system,theme_ir_asset_group_system,model_theme_ir_asset,base.group_system,1,1,1,1
|
||||
access_theme_ir_attachment,access_theme_ir_attachment,model_theme_ir_attachment,base.group_system,1,1,1,1
|
||||
access_theme_website_menu,access_theme_website_menu,model_theme_website_menu,base.group_system,1,1,1,1
|
||||
access_theme_website_page,access_theme_website_page,model_theme_website_page,base.group_system,1,1,1,1
|
||||
|
||||
|
@@ -34,8 +34,8 @@ class TestQweb(TransactionCaseWithUserDemo):
|
||||
demo_env = self.env(user=demo)
|
||||
|
||||
html = demo_env['ir.qweb']._render('website.test_template', {"user": demo}, website_id= website.id)
|
||||
asset_data = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[0]
|
||||
asset_xmlid = asset_data.attrib.get('data-asset-xmlid')
|
||||
asset_data = etree.HTML(html).xpath('//*[@data-asset-bundle]')[0]
|
||||
asset_xmlid = asset_data.attrib.get('data-asset-bundle')
|
||||
asset_version = asset_data.attrib.get('data-asset-version')
|
||||
|
||||
html = html.strip().decode('utf8')
|
||||
@@ -53,17 +53,16 @@ class TestQweb(TransactionCaseWithUserDemo):
|
||||
"asset_xmlid": asset_xmlid,
|
||||
"asset_version": asset_version,
|
||||
}
|
||||
|
||||
self.assertEqual(html, ("""<!DOCTYPE html>
|
||||
self.assertHTMLEqual(html, ("""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="http://test.external.link/style1.css"/>
|
||||
<link rel="stylesheet" href="http://test.external.link/style2.css"/>
|
||||
<link type="text/css" rel="stylesheet" href="http://test.cdn%(css)s" data-asset-xmlid="%(asset_xmlid)s" data-asset-version="%(asset_version)s"/>
|
||||
<link type="text/css" rel="stylesheet" href="http://test.external.link/style1.css"/>
|
||||
<link type="text/css" rel="stylesheet" href="http://test.external.link/style2.css"/>
|
||||
<link type="text/css" rel="stylesheet" href="http://test.cdn%(css)s" data-asset-bundle="%(asset_xmlid)s" data-asset-version="%(asset_version)s"/>
|
||||
<meta/>
|
||||
<script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
|
||||
<script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
|
||||
<script type="text/javascript" src="http://test.cdn%(js)s" data-asset-xmlid="%(asset_xmlid)s" data-asset-version="%(asset_version)s"></script>
|
||||
<script type="text/javascript" src="http://test.cdn%(js)s" data-asset-bundle="%(asset_xmlid)s" data-asset-version="%(asset_version)s"></script>
|
||||
</head>
|
||||
<body>
|
||||
<img src="http://test.external.link/img.png" loading="lazy"/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
|
||||
import odoo
|
||||
import odoo.tests
|
||||
|
||||
@@ -142,18 +144,23 @@ class TestUi(odoo.tests.HttpCase):
|
||||
def test_05_specific_website_editor(self):
|
||||
website_default = self.env['website'].search([], limit=1)
|
||||
new_website = self.env['website'].create({'name': 'New Website'})
|
||||
website_editor_assets_view = self.env.ref('website.assets_wysiwyg')
|
||||
self.env['ir.ui.view'].create({
|
||||
'name': 'Editor Extension',
|
||||
'type': 'qweb',
|
||||
'inherit_id': website_editor_assets_view.id,
|
||||
'website_id': new_website.id,
|
||||
'arch': """
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript">document.body.dataset.hello = 'world';</script>
|
||||
</xpath>
|
||||
""",
|
||||
|
||||
code = b"document.body.dataset.hello = 'world';"
|
||||
attach = self.env['ir.attachment'].create({
|
||||
'name': 'EditorExtension.js',
|
||||
'mimetype': 'text/javascript',
|
||||
'datas': base64.b64encode(code),
|
||||
})
|
||||
custom_url = '/web/content/%s/%s' % (attach.id, attach.name)
|
||||
attach.url = custom_url
|
||||
|
||||
self.env['ir.asset'].create({
|
||||
'name': 'EditorExtension',
|
||||
'bundle': 'website.assets_wysiwyg',
|
||||
'glob': custom_url,
|
||||
'website_id': new_website.id,
|
||||
})
|
||||
|
||||
self.start_tour("/?fw=%s" % website_default.id, "generic_website_editor", login='admin')
|
||||
self.start_tour("/?fw=%s" % new_website.id, "specific_website_editor", login='admin')
|
||||
|
||||
|
||||
@@ -862,7 +862,7 @@ class TestCowViewSaving(TestViewSavingCommon):
|
||||
'name': 'Main layout',
|
||||
'mode': 'extension',
|
||||
'inherit_id': base_view.id,
|
||||
'arch': '<xpath expr="//t[@t-set=\'head_website\']" position="replace"><t t-call-assets="web_editor.assets_summernote" t-js="false" groups="website.group_website_publisher"/></xpath>',
|
||||
'arch': '<xpath expr="//t[@t-set=\'head_website\']" position="replace"><t t-call-assets="assets_summernote" t-js="false" groups="website.group_website_publisher"/></xpath>',
|
||||
'key': '_website.layout',
|
||||
})
|
||||
|
||||
@@ -875,7 +875,7 @@ class TestCowViewSaving(TestViewSavingCommon):
|
||||
'name': 'Forum Layout',
|
||||
'mode': 'primary',
|
||||
'inherit_id': inherit_view.id,
|
||||
'arch': '<xpath expr="//t[@t-call-assets=\'web_editor.assets_summernote\'][@t-js=\'false\']" position="attributes"><attribute name="groups"/></xpath>',
|
||||
'arch': '<xpath expr="//t[@t-call-assets=\'assets_summernote\'][@t-js=\'false\']" position="attributes"><attribute name="groups"/></xpath>',
|
||||
'key': '_website_forum.layout',
|
||||
})])
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="asset_view_form_inherit_website" model="ir.ui.view">
|
||||
<field name="name">ir.asset.form.inherit.website</field>
|
||||
<field name="model">ir.asset</field>
|
||||
<field name="inherit_id" ref="base.asset_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="directive" position="after">
|
||||
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -516,10 +516,7 @@
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('website.theme_view_kanban')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('website.theme_view_form_preview')})]"/>
|
||||
<field name="search_view_id" ref="theme_view_search"/>
|
||||
<field name="domain" eval="[
|
||||
('category_id', 'not in', [ref('base.module_category_hidden', False), ref('base.module_category_theme_hidden', False)]),
|
||||
'|', ('category_id', '=', ref('base.module_category_theme', False)), ('category_id.parent_id', '=', ref('base.module_category_theme', False))
|
||||
]"/>
|
||||
<field name="domain" model="ir.module.module" eval="obj().get_themes_domain()"/>
|
||||
</record>
|
||||
|
||||
<record id="theme_install_act_url" model="ir.actions.act_url">
|
||||
|
||||
@@ -81,24 +81,36 @@ Assets Management
|
||||
|
||||
Managing assets in Odoo is not as straightforward as it is in some other apps.
|
||||
One of the reason is that we have a variety of situations where some, but not all
|
||||
the assets are required. For example, the needs of the web client, the point of
|
||||
sale, the website or even the mobile application are different. Also, some
|
||||
assets may be large, but are seldom needed. In that case, we sometimes want them
|
||||
the assets are required. For example, the needs of the web client, the point of
|
||||
sale, the website or even the mobile application are different. Also, some
|
||||
assets may be large, but are seldom needed. In that case, we sometimes want them
|
||||
to be loaded lazily.
|
||||
|
||||
The main idea is that we define a set of *bundles* in xml. A bundle is here defined as
|
||||
a collection of files (javascript, css, scss). In Odoo, the most important
|
||||
bundles are defined in the file *addons/web/views/webclient_templates.xml*. It looks
|
||||
like this:
|
||||
The main idea is that we define a set of *bundles* in the module manifest. A
|
||||
bundle is here defined as a list of files (xml, javascript, css, scss). Files
|
||||
are declared using `glob`_ syntax, meaning that you can declare several asset
|
||||
files using a single line. Each file found using a glob will be appended to the
|
||||
`<head>` of the page, at most once, in the order the globs are given.
|
||||
|
||||
.. code-block:: xml
|
||||
As mentionned, the bundles are declared in each module's `__manifest__.py`, under
|
||||
a dedicated `assets` key which contains a dictionary. Said dictionary will declare
|
||||
bundles (keys) with the files they contain (values). It looks like this:
|
||||
|
||||
<template id="web.assets_common" name="Common Assets (used in backend interface and website)">
|
||||
<link rel="stylesheet" type="text/css" href="/web/static/lib/jquery.ui/jquery-ui.css"/>
|
||||
...
|
||||
<script type="text/javascript" src="/web/static/src/js/boot.js"></script>
|
||||
...
|
||||
</template>
|
||||
.. code-block:: py
|
||||
|
||||
'assets': {
|
||||
'assets_backend': [
|
||||
'web/static/src/xml/**/*',
|
||||
],
|
||||
'assets_common': [
|
||||
'web/static/lib/bootstrap/**/*',
|
||||
'web/static/src/js/boot.js',
|
||||
'web/static/src/js/webclient.js',
|
||||
],
|
||||
'qunit_suite_tests': [
|
||||
'web/static/src/js/webclient_tests.js',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
The files in a bundle can then be inserted into a template by using the *t-call-assets*
|
||||
@@ -106,8 +118,8 @@ directive:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<t t-call-assets="web.assets_common" t-js="false"/>
|
||||
<t t-call-assets="web.assets_common" t-css="false"/>
|
||||
<t t-call-assets="assets_common" t-js="false"/>
|
||||
<t t-call-assets="assets_common" t-css="false"/>
|
||||
|
||||
Here is what happens when a template is rendered by the server with these directives:
|
||||
|
||||
@@ -139,52 +151,208 @@ and if necessary, will create/recreate the corresponding bundles.
|
||||
|
||||
Here are some important bundles that most developers will need to know:
|
||||
|
||||
- *web.assets_common*: this bundle contains most assets which are common to the
|
||||
- *assets_common*: this bundle contains most assets which are common to the
|
||||
web client, the website, and also the point of sale. This is supposed to contain
|
||||
lower level building blocks for the odoo framework. Note that it contains the
|
||||
lower level building blocks for the odoo framework. Note that it contains the
|
||||
*boot.js* file, which defines the odoo module system.
|
||||
|
||||
- *web.assets_backend*: this bundle contains the code specific to the web client
|
||||
(notably the web client/action manager/views)
|
||||
- *assets_backend*: this bundle contains the code specific to the web client
|
||||
(notably the web client/action manager/views) and all static XML templates used
|
||||
in the backend environment
|
||||
|
||||
- *web.assets_frontend*: this bundle is about all that is specific to the public
|
||||
- *assets_frontend*: this bundle is about all that is specific to the public
|
||||
website: ecommerce, forum, blog, event management, ...
|
||||
|
||||
Operations on asset bundles
|
||||
---------------------------
|
||||
|
||||
Adding files in an asset bundle
|
||||
-------------------------------
|
||||
Typically, handling assets is quite trivial: you just need to add some new files
|
||||
to a frequently used bundle like 'common' or 'backend'. But there are other operations
|
||||
available to cover use cases bringing additional constraints. Such cases can mostly
|
||||
be covered with the following operations.
|
||||
|
||||
The proper way to add a file located in *addons/web* to a bundle is simple:
|
||||
it is just enough to add a *script* or a *stylesheet* tag to the bundle in the
|
||||
file *webclient_templates.xml*. But when we work in a different addon, we need
|
||||
to add a file from that addon. In that case, it should be done in three steps:
|
||||
a) Add one or multiple file(s): append
|
||||
|
||||
1. add a *assets.xml* file in the *views/* folder
|
||||
2. add the string 'views/assets.xml' in the 'data' key in the manifest file
|
||||
3. create an inherited view of the desired bundle, and add the file(s) with an
|
||||
xpath expression. For example,
|
||||
The proper way to add a file to a bundle in any addon is simple: it is just enough
|
||||
to add a glob path to the bundle in the file *__manifest__.py* like so:
|
||||
|
||||
.. code-block:: xml
|
||||
.. code-block:: py
|
||||
|
||||
<template id="assets_backend" name="helpdesk assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="//script[last()]" position="after">
|
||||
<link rel="stylesheet" type="text/scss" href="/helpdesk/static/src/scss/helpdesk.scss"/>
|
||||
<script type="text/javascript" src="/helpdesk/static/src/js/helpdesk_dashboard.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
'assets_common': [
|
||||
'my_addon/static/src/js/**/*',
|
||||
],
|
||||
|
||||
By default, adding a simple string to a bundle will append the files matching the
|
||||
glob at the end of the bundle.
|
||||
|
||||
b) Add one or multiple file(s) at the beginning of the list: prepend
|
||||
|
||||
Sometimes you need to put a certain file before the others in a bundle, when
|
||||
loading css file for example. In this case, you can use the *prepend* directive
|
||||
like so:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
'assets_common': [
|
||||
('prepend', 'my_addon/static/src/css/bootstrap_overridden.scss'),
|
||||
],
|
||||
|
||||
c) Add one or multiple file(s) before a specific file: before
|
||||
|
||||
Prepending a file at the beginning of a bundle might not be precise enough. The
|
||||
*before* directive can be used to add the given files right before the target
|
||||
file. The syntax is `('before', target, glob)`:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
'assets_common': [
|
||||
('before', 'web/static/src/css/bootstrap_overridden.scss', 'my_addon/static/src/css/bootstrap_overridden.scss'),
|
||||
],
|
||||
|
||||
d) Add one or multiple file(s) after a specofic file: after
|
||||
|
||||
Same as *before*, with the resulting files appended after the target file. The
|
||||
syntax is `('after', target, glob)`:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
'assets_common': [
|
||||
('after', 'web/static/src/css/list_view.scss', 'my_addon/static/src/css/list_view.scss'),
|
||||
],
|
||||
|
||||
e) Use nested bundles: include
|
||||
|
||||
The *include* directive is a way to use a same bundle in other bundles to minimize
|
||||
the size of your manifest. In Odoo we use sub bundles (prefixed with an underscore
|
||||
by convention) to batch glob files used in multiple other bundles. You can then
|
||||
specify the sub bundle like this:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
'assets_common': [
|
||||
('include', 'web._primary_variables'),
|
||||
],
|
||||
|
||||
f) Remove one or multiple file(s): remove
|
||||
|
||||
In some additional module you may want to get rid of the call of a certain asset
|
||||
in a bundle. Any file can be removed from an existing bundle using the *remove*
|
||||
directive:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
'assets_common': [
|
||||
('remove', 'web/static/src/js/boot.js'),
|
||||
],
|
||||
|
||||
g) Replace an asset file with one or multiple file(s): replace
|
||||
|
||||
Let us now say that an asset need not only to be removed, but you also want to insert
|
||||
your new version of that asset at the same exact position. This can be done with
|
||||
the *replace* directive, using a 3-element tuple `('replace', target, asset)`:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
'assets_common': [
|
||||
('replace', 'web/static/src/js/boot.js', 'my_addon/static/src/js/boot.js'),
|
||||
],
|
||||
|
||||
Note that directives targetting a certain file (i.e. *before*, *after*, *replace* and *remove*)
|
||||
need that file to be declared beforehand, either in manifests higher up in the hierarchy
|
||||
or in 'ir.asset' records with a lower sequence.
|
||||
|
||||
.. note ::
|
||||
|
||||
Note that the files in a bundle are all loaded immediately when the user loads the
|
||||
odoo web client. This means that the files are transferred through the network
|
||||
every time (except when the browser cache is active). In some cases, it may be
|
||||
better to lazyload some assets. For example, if a widget requires a large
|
||||
odoo web client. This means that the files are transferred through the network
|
||||
every time (except when the browser cache is active). In some cases, it may be
|
||||
better to lazyload some assets. For example, if a widget requires a large
|
||||
library, and that widget is not a core part of the experience, then it may be
|
||||
a good idea to only load the library when the widget is actually created. The
|
||||
widget class has actually builtin support just for this use case. (see section
|
||||
:ref:`reference/javascript_reference/qweb`)
|
||||
|
||||
Assets loading order
|
||||
-------------------
|
||||
|
||||
The order in which assets are loaded is sometimes critical and must be deterministic,
|
||||
mostly for stylesheets priorities and setup scripts. Assets in Odoo are processed
|
||||
as follows.
|
||||
|
||||
1. When an asset bundle is called (e.g. `t-call-assets="assets_common"`), an empty
|
||||
list of assets is generated
|
||||
|
||||
2. All records of type 'ir.asset' matching the bundle will be fetched and sorted
|
||||
by sequence number. Then all records with a sequence strictly less than 16 will
|
||||
be processed and applied to the current list of assets.
|
||||
|
||||
3. All modules declaring assets for said bundle in their manifest will apply their
|
||||
assets operations to this list. This is done following the order of modules dependencies
|
||||
(e.g. 'web' assets will be processed before 'website'). If a directive tries to add
|
||||
a file already present in the list, nothing is done for that file. In other word,
|
||||
only the first occurrence of a file is kept in the list.
|
||||
|
||||
4. The remaining 'ir.asset' records (those with a sequence greater than or equal
|
||||
to 16) are eventually processed and applied as well.
|
||||
|
||||
Assets declared in the manifest may need to be loaded in a particular order, for
|
||||
example `jquery.js` must be loaded before all other jquery scripts when loading the
|
||||
lib folder. One solution would be to create an 'ir.asset' record with a lower sequence
|
||||
or a 'prepend' directive, but there is another simpler way to do so.
|
||||
|
||||
Since the unicity of each file path in the list of assets is guaranteed, you can
|
||||
mention any specific file before a glob that includes it. That file will thus appear
|
||||
in the list before all the others included in the glob.
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
'assets_common': [
|
||||
'my_addon/static/lib/jquery/jquery.js',
|
||||
'my_addon/static/lib/jquery/**/*',
|
||||
],
|
||||
|
||||
.. note ::
|
||||
|
||||
A module *b* removing/replacing the assets declared in a module *a* will have
|
||||
to depend on it. Trying to operate on assets that have yet to be declared will
|
||||
result in an error.
|
||||
|
||||
The 'ir.asset' model
|
||||
--------------------
|
||||
|
||||
In most cases the assets declared in the manifest will largely suffice. But Odoo
|
||||
being highly customizable requires to modify things as critical as defining assets
|
||||
to be editted in place. A model 'ir.asset' exists to do such things. Records will
|
||||
be associated to a `bundle` and apply their `glob` (and `target` if any) to the
|
||||
list of assets using according to their `directive`. Each record of 'ir.asset' has
|
||||
the following fields:
|
||||
|
||||
.. autoclass:: odoo.addons.base.models.ir_asset.IrAsset
|
||||
|
||||
``name``
|
||||
Name of the asset record (for identification purpose).
|
||||
|
||||
``bundle``
|
||||
Bundle in which the asset will be applied.
|
||||
|
||||
``directive`` (default='append')
|
||||
Directive to use on the bundle.
|
||||
|
||||
``glob``
|
||||
Glob string defining:
|
||||
a) a path to a file/folder in the Odoo file system
|
||||
b) a URL to an attachment
|
||||
|
||||
``target``
|
||||
Target file to specify a position in the bundle. Can only be used with the
|
||||
directives 'replace', 'before' and 'after'.
|
||||
|
||||
``active`` (default=True)
|
||||
|
||||
``sequence`` (default=16)
|
||||
Loading order of the asset records (ascending). A sequence lower than 16 means
|
||||
that the asset will be processed __before__ the ones declared in the manifest.
|
||||
|
||||
What to do if a file is not loaded/updated
|
||||
------------------------------------------
|
||||
|
||||
@@ -225,7 +393,7 @@ another file. There are two ways of sharing code between files:
|
||||
|
||||
- use a module system that will provide a way for each modules to export or import
|
||||
values, and will make sure that they are loaded in a proper order.
|
||||
|
||||
|
||||
While it's possible to work in the global scope, this has a number of issues:
|
||||
|
||||
It is difficult to ensure that implementation details are not exposed by work done in the global scope directly.
|
||||
@@ -241,14 +409,14 @@ It is difficult to ensure that implementation details are not exposed by work do
|
||||
|
||||
For most Odoo code, we want to use a module system. Because of the way assets
|
||||
work in Odoo (and in particular, the fact that each installed odoo addon can
|
||||
modify the list of files contained in a bundle), Odoo has to resolve modules
|
||||
modify the list of files contained in a bundle), Odoo has to resolve modules
|
||||
browser side. To do that, Odoo provides a small module system described just
|
||||
below (see :ref:`reference/javascript_reference/odoo_module`).
|
||||
|
||||
However, Odoo also provides support for native javascript modules (see
|
||||
However, Odoo also provides support for native javascript modules (see
|
||||
:ref:`reference/javascript_reference/js_module`). These modules
|
||||
will simply be translated by the server into odoo modules. It is encouraged to
|
||||
write all javascript code as a native module, for a better IDE integration. In
|
||||
write all javascript code as a native module, for a better IDE integration. In
|
||||
the future, the Odoo module system should be considered an implementation detail,
|
||||
not the primary way to write javascript code.
|
||||
|
||||
@@ -442,9 +610,9 @@ error). This file will then be translated into an Odoo module that look like thi
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
odoo.define('@web/file_a', function (require) {
|
||||
'use strict';
|
||||
let __exports = {};
|
||||
odoo.define('@web/file_a', function (require) {
|
||||
'use strict';
|
||||
let __exports = {};
|
||||
|
||||
const { someFunction } = require("@web/file_b");
|
||||
|
||||
@@ -486,14 +654,14 @@ The file ``file_b`` can import ``file_a`` like this:
|
||||
.. code-block:: javascript
|
||||
|
||||
/** @odoo-module **/
|
||||
import {something} from `./file_a`
|
||||
import {something} from `./file_a`
|
||||
|
||||
But ``file_c`` need to use the full name:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
/** @odoo-module **/
|
||||
import {something} from `@web/file_a`
|
||||
import {something} from `@web/file_a`
|
||||
|
||||
|
||||
Aliased modules
|
||||
@@ -2669,6 +2837,9 @@ do that, several steps should be done.
|
||||
The ``updateControlPanel`` is the main method to customize the content in controlpanel.
|
||||
For more information, look into the `control_panel_renderer.js <https://github.com/odoo/odoo/blob/13.0/addons/web/static/src/js/views/control_panel/control_panel_renderer.js#L130>`_ file.
|
||||
|
||||
.. _glob:
|
||||
https://en.wikipedia.org/wiki/Glob_(programming)
|
||||
|
||||
.. _.appendTo():
|
||||
https://api.jquery.com/appendTo/
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
@@ -148,6 +148,7 @@ STATES = [
|
||||
('to install', 'To be installed'),
|
||||
]
|
||||
|
||||
|
||||
class Module(models.Model):
|
||||
_name = "ir.module.module"
|
||||
_rec_name = "shortdesc"
|
||||
|
||||
@@ -9,7 +9,6 @@ from time import time
|
||||
|
||||
from lxml import html
|
||||
from lxml import etree
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import api, models, tools
|
||||
from odoo.tools.safe_eval import assert_valid_codeobj, _BUILTINS, _SAFE_OPCODES
|
||||
@@ -19,6 +18,7 @@ from odoo.modules.module import get_resource_path
|
||||
|
||||
from odoo.addons.base.models.qweb import QWeb, Contextifier
|
||||
from odoo.addons.base.models.assetsbundle import AssetsBundle
|
||||
from odoo.addons.base.models.ir_asset import can_aggregate, STYLE_EXTENSIONS, SCRIPT_EXTENSIONS
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,7 +159,7 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
if len(el):
|
||||
raise SyntaxError("t-call-assets cannot contain children nodes")
|
||||
|
||||
# nodes = self._get_asset_nodes(xmlid, options, css=css, js=js, debug=values.get('debug'), async=async, values=values)
|
||||
# nodes = self._get_asset_nodes(bundle, options, css=css, js=js, debug=values.get('debug'), async=async, values=values)
|
||||
#
|
||||
# for index, (tagName, t_attrs, content) in enumerate(nodes):
|
||||
# if index:
|
||||
@@ -216,7 +216,7 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
ast.keyword('async_load', self._get_attr_bool(el.get('async_load', False))),
|
||||
ast.keyword('defer_load', self._get_attr_bool(el.get('defer_load', False))),
|
||||
ast.keyword('lazy_load', self._get_attr_bool(el.get('lazy_load', False))),
|
||||
ast.keyword('values', ast.Name(id='values', ctx=ast.Load())),
|
||||
ast.keyword('media', ast.Constant(el.get('media'))),
|
||||
],
|
||||
starargs=None, kwargs=None
|
||||
)
|
||||
@@ -281,92 +281,89 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
|
||||
# method called by computing code
|
||||
|
||||
def get_asset_bundle(self, xmlid, files, env=None, css=True, js=True):
|
||||
return AssetsBundle(xmlid, files, env=env, css=css, js=js)
|
||||
def get_asset_bundle(self, bundle_name, files, env=None, css=True, js=True):
|
||||
return AssetsBundle(bundle_name, files, env=env, css=css, js=js)
|
||||
|
||||
def _get_asset_nodes(self, xmlid, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, values=None):
|
||||
def _get_asset_nodes(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
|
||||
"""Generates asset nodes.
|
||||
If debug=assets, the assets will be regenerated when a file which composes them has been modified.
|
||||
Else, the assets will be generated only once and then stored in cache.
|
||||
"""
|
||||
if debug and 'assets' in debug:
|
||||
return self._generate_asset_nodes(xmlid, options, css, js, debug, async_load, defer_load, lazy_load, values)
|
||||
return self._generate_asset_nodes(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
|
||||
else:
|
||||
return self._generate_asset_nodes_cache(xmlid, options, css, js, debug, async_load, defer_load, lazy_load, values)
|
||||
return self._generate_asset_nodes_cache(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
|
||||
|
||||
@tools.conditional(
|
||||
# in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear
|
||||
# by restarting the server after updating the source code (or using the "Clear server cache" in debug tools)
|
||||
'xml' not in tools.config['dev_mode'],
|
||||
tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', keys=("website_id",)),
|
||||
tools.ormcache_context('bundle', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', keys=("website_id",)),
|
||||
)
|
||||
def _generate_asset_nodes_cache(self, xmlid, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, values=None):
|
||||
return self._generate_asset_nodes(xmlid, options, css, js, debug, async_load, defer_load, lazy_load, values)
|
||||
def _generate_asset_nodes_cache(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
|
||||
return self._generate_asset_nodes(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
|
||||
|
||||
def _generate_asset_nodes(self, xmlid, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, values=None):
|
||||
files, remains = self._get_asset_content(xmlid, options)
|
||||
asset = self.get_asset_bundle(xmlid, files, env=self.env, css=css, js=js)
|
||||
remains = [node for node in remains if (css and node[0] == 'link') or (js and node[0] != 'link')]
|
||||
def _generate_asset_nodes(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
|
||||
nodeAttrs = None
|
||||
if css and media:
|
||||
nodeAttrs = {
|
||||
'media': media,
|
||||
}
|
||||
files, remains = self._get_asset_content(bundle, options, nodeAttrs)
|
||||
asset = self.get_asset_bundle(bundle, files, env=self.env, css=css, js=js)
|
||||
remains = [node for node in remains if (css and node[0] == 'link') or (js and node[0] == 'script')]
|
||||
return remains + asset.to_node(css=css, js=js, debug=debug, async_load=async_load, defer_load=defer_load, lazy_load=lazy_load)
|
||||
|
||||
def _get_asset_link_urls(self, xmlid, options):
|
||||
asset_nodes = self._get_asset_nodes(xmlid, options, js=False)
|
||||
def _get_asset_link_urls(self, bundle, options):
|
||||
asset_nodes = self._get_asset_nodes(bundle, options, js=False)
|
||||
return [node[1]['href'] for node in asset_nodes if node[0] == 'link']
|
||||
|
||||
@tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', keys=("website_id",))
|
||||
def _get_asset_content(self, xmlid, options):
|
||||
@tools.ormcache_context('bundle', 'options.get("lang", "en_US")', keys=("website_id",))
|
||||
def _get_asset_content(self, bundle, options, nodeAttrs=None):
|
||||
options = dict(options,
|
||||
inherit_branding=False, inherit_branding_auto=False,
|
||||
edit_translations=False, translatable=False,
|
||||
rendering_bundle=True)
|
||||
|
||||
options['website_id'] = self.env.context.get('website_id')
|
||||
IrQweb = self.env['ir.qweb'].with_context(options)
|
||||
|
||||
def can_aggregate(url):
|
||||
return not urls.url_parse(url).scheme and not urls.url_parse(url).netloc and not url.startswith('/web/assets')
|
||||
|
||||
# TODO: This helper can be used by any template that wants to embedd the backend.
|
||||
# It is currently necessary because the ir.ui.view bundle inheritance does not
|
||||
# match the module dependency graph.
|
||||
def get_modules_order():
|
||||
if request:
|
||||
from odoo.addons.web.controllers.main import module_boot
|
||||
return json.dumps(module_boot())
|
||||
return '[]'
|
||||
template = IrQweb._render(xmlid, {"get_modules_order": get_modules_order})
|
||||
asset_paths = self.env['ir.asset']._get_asset_paths(bundle=bundle, css=True, js=True)
|
||||
|
||||
files = []
|
||||
remains = []
|
||||
for el in html.fragments_fromstring(template):
|
||||
if isinstance(el, html.HtmlElement):
|
||||
href = el.get('href', '')
|
||||
src = el.get('src', '')
|
||||
atype = el.get('type')
|
||||
media = el.get('media')
|
||||
for path, *_ in asset_paths:
|
||||
ext = path.split('.')[-1]
|
||||
is_js = ext in SCRIPT_EXTENSIONS
|
||||
is_css = ext in STYLE_EXTENSIONS
|
||||
if not is_js and not is_css:
|
||||
continue
|
||||
|
||||
if can_aggregate(href) and (el.tag == 'style' or (el.tag == 'link' and el.get('rel') == 'stylesheet')):
|
||||
if href.endswith('.sass'):
|
||||
atype = 'text/sass'
|
||||
elif href.endswith('.scss'):
|
||||
atype = 'text/scss'
|
||||
elif href.endswith('.less'):
|
||||
atype = 'text/less'
|
||||
if atype not in ('text/less', 'text/scss', 'text/sass'):
|
||||
atype = 'text/css'
|
||||
path = [segment for segment in href.split('/') if segment]
|
||||
filename = get_resource_path(*path) if path else None
|
||||
files.append({'atype': atype, 'url': href, 'filename': filename, 'content': el.text, 'media': media})
|
||||
elif can_aggregate(src) and el.tag == 'script':
|
||||
atype = 'text/javascript'
|
||||
path = [segment for segment in src.split('/') if segment]
|
||||
filename = get_resource_path(*path) if path else None
|
||||
files.append({'atype': atype, 'url': src, 'filename': filename, 'content': el.text, 'media': media})
|
||||
else:
|
||||
remains.append((el.tag, OrderedDict(el.attrib), el.text))
|
||||
mimetype = 'text/javascript' if is_js else 'text/%s' % ext
|
||||
if can_aggregate(path):
|
||||
segments = [segment for segment in path.split('/') if segment]
|
||||
files.append({
|
||||
'atype': mimetype,
|
||||
'url': path,
|
||||
'filename': get_resource_path(*segments) if segments else None,
|
||||
'content': '',
|
||||
'media': nodeAttrs and nodeAttrs.get('media'),
|
||||
})
|
||||
else:
|
||||
# the other cases are ignored
|
||||
pass
|
||||
if is_js:
|
||||
tag = 'script'
|
||||
attributes = {
|
||||
"type": mimetype,
|
||||
"src": path,
|
||||
}
|
||||
else:
|
||||
tag = 'link'
|
||||
attributes = {
|
||||
"type": mimetype,
|
||||
"rel": "stylesheet",
|
||||
"href": path,
|
||||
'media': nodeAttrs and nodeAttrs.get('media'),
|
||||
}
|
||||
remains.append((tag, attributes, ''))
|
||||
|
||||
return (files, remains)
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"access_res_users_apikeys_description_employee","API key wizard employees","model_res_users_apikeys_description","group_user",1,0,1,0
|
||||
"access_res_users_apikeys_description_portal","API key wizard","model_res_users_apikeys_description","group_portal",1,0,1,0
|
||||
"access_res_users_apikeys_show_employee","API key result employees","model_res_users_apikeys_show","group_user",1,0,1,0
|
||||
"access_ir_asset_group_system","ir_asset_group_system","model_ir_asset","group_system",1,1,1,1
|
||||
"access_ir_actions_group_system","ir_actions_group_system","model_ir_actions_actions","group_system",1,1,1,1
|
||||
"access_ir_actions_act_window_system","ir_actions_act_window_system","model_ir_actions_act_window","group_system",1,1,1,1
|
||||
"access_ir_actions_act_window_close_group_system","ir_actions_act_window_close_group_system","model_ir_actions_act_window_close","group_system",1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="asset_view_form" model="ir.ui.view">
|
||||
<field name="model">ir.asset</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Assets">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="bundle"/>
|
||||
<field name="directive"/>
|
||||
<field name="sequence"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="target" attrs="{ 'invisible': [('directive', '!=', 'replace')] }"/>
|
||||
<field name="glob"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="asset_view_tree" model="ir.ui.view">
|
||||
<field name="model">ir.asset</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Assets">
|
||||
<field name="name"/>
|
||||
<field name="bundle"/>
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="asset_view_search" model="ir.ui.view">
|
||||
<field name="model">ir.asset</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Assets">
|
||||
<field name="name"/>
|
||||
<field name="bundle"/>
|
||||
<field name="directive"/>
|
||||
<field name="sequence"/>
|
||||
<field name="glob"/>
|
||||
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_asset" model="ir.actions.act_window">
|
||||
<field name="name">Assets</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">ir.asset</field>
|
||||
<field name="view_id" ref="asset_view_tree"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem action="action_asset" id="menu_action_asset" parent="base.next_id_9"/>
|
||||
</odoo>
|
||||
@@ -8,7 +8,55 @@
|
||||
'depends': ['base'],
|
||||
'installable': True,
|
||||
'data': [
|
||||
"data/ir_asset.xml",
|
||||
"views/views.xml",
|
||||
],
|
||||
'auto_install': False,
|
||||
|
||||
'assets': {
|
||||
'test_assetsbundle.bundle2': [
|
||||
'test_assetsbundle/static/src/css/test_cssfile1.css',
|
||||
],
|
||||
'test_assetsbundle.bundle3': [
|
||||
'test_assetsbundle/static/src/scss/test_file1.scss',
|
||||
],
|
||||
'test_assetsbundle.bundle4': [
|
||||
'test_assetsbundle/static/src/js/test_jsfile1.js',
|
||||
'test_assetsbundle/static/src/js/test_jsfile2.js',
|
||||
'http://test.external.link/javascript1.js',
|
||||
|
||||
'test_assetsbundle/static/src/css/test_cssfile1.css',
|
||||
'http://test.external.link/style1.css',
|
||||
'test_assetsbundle/static/src/css/test_cssfile2.css',
|
||||
|
||||
'test_assetsbundle/static/src/js/test_jsfile3.js',
|
||||
'http://test.external.link/javascript2.js',
|
||||
|
||||
'http://test.external.link/style2.css',
|
||||
],
|
||||
'test_assetsbundle.manifest1': [
|
||||
'test_assetsbundle/static/src/*/**',
|
||||
],
|
||||
'test_assetsbundle.manifest2': [
|
||||
'test_assetsbundle/static/src/js/test_jsfile1.js',
|
||||
'test_assetsbundle/static/src/*/**',
|
||||
],
|
||||
'test_assetsbundle.manifest3': [
|
||||
'test_assetsbundle/static/src/js/test_jsfile3.js',
|
||||
'test_assetsbundle/static/src/*/**',
|
||||
],
|
||||
'test_assetsbundle.manifest4': [
|
||||
'test_assetsbundle/static/src/js/test_jsfile3.js',
|
||||
],
|
||||
'test_assetsbundle.manifest5': [
|
||||
'test_assetsbundle/static/src/js/test_jsfile1.js',
|
||||
'test_assetsbundle/static/src/js/test_jsfile2.js',
|
||||
'test_assetsbundle/static/src/js/test_jsfile3.js',
|
||||
'test_assetsbundle/static/src/js/test_jsfile4.js',
|
||||
],
|
||||
'test_assetsbundle.manifest6': [
|
||||
('include', 'test_assetsbundle.manifest4'),
|
||||
],
|
||||
'test_assetsbundle.manifest_multi_module1': [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,4 @@ class TestAssetsBundleController(Controller):
|
||||
@route('/test_assetsbundle/js', type='http', auth='user')
|
||||
def bundle(self):
|
||||
env = request.env(user=SUPERUSER_ID)
|
||||
bundle = env.ref('test_assetsbundle.bundle1')
|
||||
views = env['ir.ui.view'].search([('inherit_id', '=', bundle.id)])
|
||||
return views.with_context(check_view_ids=views.ids)._render_template('test_assetsbundle.template1')
|
||||
return env['ir.ui.view']._render_template('test_assetsbundle.template1')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="test_assetsbundle.bundle1" model="ir.asset">
|
||||
<field name="name">Bundle 1</field>
|
||||
<field name="bundle">test_assetsbundle.bundle1</field>
|
||||
<field name="glob">test_assetsbundle/static/src/js/test_jsfile[!4].js</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1 @@
|
||||
<!-- Can access me -->
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,4 @@
|
||||
<odoo>
|
||||
<template id="test_assetsbundle.bundle1">
|
||||
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile1.js"></script>
|
||||
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile2.js"></script>
|
||||
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile3.js"></script>
|
||||
</template>
|
||||
|
||||
<template id="test_assetsbundle.template1" name="test template 1">
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -16,29 +10,6 @@
|
||||
</html>
|
||||
</template>
|
||||
|
||||
<template id="test_assetsbundle.bundle2">
|
||||
<link rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile1.css"/>
|
||||
</template>
|
||||
|
||||
<template id="test_assetsbundle.bundle3">
|
||||
<link rel="stylesheet" type="text/scss" href="/test_assetsbundle/static/src/scss/test_file1.scss"/>
|
||||
</template>
|
||||
|
||||
<template id="test_assetsbundle.bundle4">
|
||||
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile1.js"></script>
|
||||
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile2.js"></script>
|
||||
<script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile1.css"/>
|
||||
<link rel="stylesheet" href="http://test.external.link/style1.css"/>
|
||||
<link rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile2.css"/>
|
||||
|
||||
<script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile3.js"></script>
|
||||
<script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="http://test.external.link/style2.css"/>
|
||||
</template>
|
||||
|
||||
<template id="test_assetsbundle.template2" name="test template 2">
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
+11
-11
@@ -55,7 +55,7 @@ from .tools.func import lazy_property
|
||||
from .tools import ustr, consteq, frozendict, pycompat, unique, date_utils
|
||||
from .tools.mimetypes import guess_mimetype
|
||||
from .tools._vendor import sessions
|
||||
from .modules.module import module_manifest
|
||||
from .modules.module import read_manifest
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
rpc_request = logging.getLogger(__name__ + '.rpc.request')
|
||||
@@ -1303,21 +1303,21 @@ class Root(object):
|
||||
controllers and configure them. """
|
||||
# TODO should we move this to ir.http so that only configured modules are served ?
|
||||
statics = {}
|
||||
manifests = addons_manifest
|
||||
for addons_path in odoo.addons.__path__:
|
||||
for module in sorted(os.listdir(str(addons_path))):
|
||||
if module not in addons_manifest:
|
||||
if module not in manifests:
|
||||
# Deal with the manifest first
|
||||
mod_path = opj(addons_path, module)
|
||||
manifest_path = module_manifest(mod_path)
|
||||
manifest = read_manifest(addons_path, module)
|
||||
if not manifest or (not manifest.get('installable', True) and 'assets' not in manifest):
|
||||
continue
|
||||
manifest['addons_path'] = addons_path
|
||||
manifests[module] = manifest
|
||||
# Then deal with the statics
|
||||
path_static = opj(addons_path, module, 'static')
|
||||
if manifest_path and os.path.isdir(path_static):
|
||||
with open(manifest_path, 'rb') as fd:
|
||||
manifest_data = fd.read()
|
||||
manifest = ast.literal_eval(pycompat.to_text(manifest_data))
|
||||
if not manifest.get('installable', True):
|
||||
continue
|
||||
manifest['addons_path'] = addons_path
|
||||
if os.path.isdir(path_static):
|
||||
_logger.debug("Loading %s", module)
|
||||
addons_manifest[module] = manifest
|
||||
statics['/%s/static' % module] = path_static
|
||||
|
||||
if statics:
|
||||
|
||||
@@ -249,6 +249,14 @@ def module_manifest(path):
|
||||
if os.path.isfile(opj(path, manifest_name)):
|
||||
return opj(path, manifest_name)
|
||||
|
||||
def read_manifest(addons_path, module):
|
||||
mod_path = opj(addons_path, module)
|
||||
manifest_path = module_manifest(mod_path)
|
||||
if manifest_path:
|
||||
with tools.file_open(manifest_path, 'r') as fd:
|
||||
manifest_data = fd.read()
|
||||
return ast.literal_eval(manifest_data)
|
||||
|
||||
def get_module_root(path):
|
||||
"""
|
||||
Get closest module's root beginning from path
|
||||
|
||||
@@ -174,6 +174,13 @@ class RecordCapturer:
|
||||
# ------------------------------------------------------------
|
||||
class OdooSuite(unittest.suite.TestSuite):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
from odoo.http import root
|
||||
if not root._loaded:
|
||||
root.load_addons()
|
||||
root._loaded = True
|
||||
|
||||
if sys.version_info < (3, 8):
|
||||
# Partial backport of bpo-24412, merged in CPython 3.8
|
||||
|
||||
@@ -282,6 +289,27 @@ class MetaCase(type):
|
||||
cls.test_sequence = 0
|
||||
|
||||
|
||||
def _normalize_arch_for_assert(arch_string, parser_method="xml"):
|
||||
"""Takes some xml and normalize it to make it comparable to other xml
|
||||
in particular, blank text is removed, and the output is pretty-printed
|
||||
:param arch_string: the string representing an XML arch
|
||||
:type arch_string: str
|
||||
:param parser_method: an string representing which lxml.Parser class to use
|
||||
when normalizing both archs. Takes either "xml" or "html"
|
||||
:type parser_method: str
|
||||
:return: the normalized arch
|
||||
:rtype str:
|
||||
"""
|
||||
Parser = None
|
||||
if parser_method == 'xml':
|
||||
Parser = etree.XMLParser
|
||||
elif parser_method == 'html':
|
||||
Parser = etree.HTMLParser
|
||||
parser = Parser(remove_blank_text=True)
|
||||
arch_string = etree.fromstring(arch_string, parser=parser)
|
||||
return etree.tostring(arch_string, pretty_print=True, encoding='unicode')
|
||||
|
||||
|
||||
class BaseCase(unittest.TestCase, metaclass=MetaCase):
|
||||
""" Subclass of TestCase for Odoo-specific code. This class is abstract and
|
||||
expects self.registry, self.cr and self.uid to be initialized by subclasses.
|
||||
@@ -607,6 +635,28 @@ class BaseCase(unittest.TestCase, metaclass=MetaCase):
|
||||
for c1, c2 in izip_longest(n1, n2):
|
||||
self.assertTreesEqual(c1, c2, msg)
|
||||
|
||||
def _assertXMLEqual(self, original, expected, parser="xml"):
|
||||
"""Asserts that two xmls archs are equal
|
||||
:param original: the xml arch to test
|
||||
:type original: str
|
||||
:param expected: the xml arch of reference
|
||||
:type expected: str
|
||||
:param parser: an string representing which lxml.Parser class to use
|
||||
when normalizing both archs. Takes either "xml" or "html"
|
||||
:type parser: str
|
||||
"""
|
||||
if original:
|
||||
original = _normalize_arch_for_assert(original, parser)
|
||||
if expected:
|
||||
expected = _normalize_arch_for_assert(expected, parser)
|
||||
self.assertEqual(original, expected)
|
||||
|
||||
def assertXMLEqual(self, original, expected):
|
||||
return self._assertXMLEqual(original, expected)
|
||||
|
||||
def assertHTMLEqual(self, original, expected):
|
||||
return self._assertXMLEqual(original, expected, 'html')
|
||||
|
||||
|
||||
savepoint_seq = itertools.count()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/** Should not be read by anyone */
|
||||
@@ -0,0 +1 @@
|
||||
<!-- Should not be read by anyone -->
|
||||
Reference in New Issue
Block a user