diff --git a/addons/test_website/tests/test_qweb.py b/addons/test_website/tests/test_qweb.py index e498232e09a..6f165f50d6f 100644 --- a/addons/test_website/tests/test_qweb.py +++ b/addons/test_website/tests/test_qweb.py @@ -34,35 +34,42 @@ class TestQweb(TransactionCaseWithUserDemo): demo_env = self.env(user=demo) html = demo_env['ir.qweb']._render('test_website.test_template', {"user": demo}, website_id=website.id) - 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') + asset_bundle_xmlid = 'test_website.test_bundle' + qweb = self.env['ir.qweb'] + files, _ = qweb._get_asset_content(asset_bundle_xmlid) + bundle = qweb._get_asset_bundle(asset_bundle_xmlid, files, env=self.env, css=True, js=True) + + asset_version_js = bundle.get_version('js') + asset_version_css = bundle.get_version('css') html = html.strip() html = re.sub(r'\?unique=[^"]+', '', html).encode('utf8') - attachments = demo_env['ir.attachment'].search([('url', '=like', '/web/assets/%-%/test_website.test_bundle.%')]) - self.assertEqual(len(attachments), 2) + css_attachement = demo_env['ir.attachment'].search([('url', '=like', f'/web/assets/%-{asset_version_css}/{website.id}/test_website.test_bundle.%')]) + js_attachement = demo_env['ir.attachment'].search([('url', '=like', f'/web/assets/%-{asset_version_js}/{website.id}/test_website.test_bundle.%')]) + self.assertEqual(len(css_attachement), 1) + self.assertEqual(len(js_attachement), 1) format_data = { - "js": attachments[0].url, - "css": attachments[1].url, + "css": css_attachement.url, + "js": js_attachement.url, "user_id": demo.id, "filename": "Marc%20Demo", "alt": "Marc Demo", - "asset_xmlid": asset_xmlid, - "asset_version": asset_version, + "asset_xmlid": asset_bundle_xmlid, + "asset_version_css": asset_version_css, + "asset_version_js": asset_version_js, } self.assertHTMLEqual(html, (""" - + - + diff --git a/addons/web/tests/test_assets_xml.py b/addons/web/tests/test_assets_xml.py index 5c5866c0bbd..be1f995d583 100644 --- a/addons/web/tests/test_assets_xml.py +++ b/addons/web/tests/test_assets_xml.py @@ -73,9 +73,9 @@ class TestStaticInheritanceCommon(odoo.tests.TransactionCase): 'content': None, 'media': None, }) - asset = AssetsBundle('web.test_bundle', files, env=self.env, css=False, js=True) + asset = AssetsBundle('web.test_bundle', files, env=self.env, css=False, js=True, debug='assets' if debug else '') # to_node return the files descriptions and generate attachments. - asset.to_node(css=False, js=False, debug=debug and 'assets' or '') + asset.to_node(css=False, js=False) content = asset.xml(show_inherit_info=debug) return f'\n{content}\n' diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index 26e0f3c9388..476c3f8e3c3 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -106,8 +106,8 @@ class IrQWeb(models.AbstractModel): return irQweb - def _get_asset_bundle(self, xmlid, files, env=None, css=True, js=True): - return AssetsBundleMultiWebsite(xmlid, files, env=env) + def _get_asset_bundle(self, xmlid, files, env=None, css=True, js=True, debug=False): + return AssetsBundleMultiWebsite(xmlid, files, env=env, css=css, js=js, debug=debug) def _post_processing_att(self, tagName, atts): if atts.get('data-no-post-process'): diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py index 3759ad9982e..6d66755e8ec 100644 --- a/odoo/addons/base/models/assetsbundle.py +++ b/odoo/addons/base/models/assetsbundle.py @@ -114,7 +114,7 @@ class AssetsBundle(object): TRACKED_BUNDLES = ['web.assets_common', 'web.assets_backend'] - def __init__(self, name, files, env=None, css=True, js=True): + def __init__(self, name, files, env=None, css=True, js=True, debug=None): """ :param name: bundle name :param files: files to be added to the bundle @@ -131,34 +131,47 @@ class AssetsBundle(object): self.user_direction = self.env['res.lang']._lang_get( self.env.context.get('lang') or self.env.user.lang ).direction + self.has_css = css + self.has_js = js + self._checksum_cache = {} + self.is_debug_assets = debug and 'assets' in debug # asset-wide html "media" attribute for f in files: + params = { + 'url': f['url'], + 'filename': f['filename'], + 'inline': f['content'], + 'last_modified': None if self.is_debug_assets else f.get('last_modified'), + } if css: + css_params = { + 'media': f['media'], + 'direction': self.user_direction, + } if f['atype'] == 'text/sass': - self.stylesheets.append(SassStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) + self.stylesheets.append(SassStylesheetAsset(self, **params, **css_params)) elif f['atype'] == 'text/scss': - self.stylesheets.append(ScssStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) + self.stylesheets.append(ScssStylesheetAsset(self, **params, **css_params)) elif f['atype'] == 'text/less': - self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) + self.stylesheets.append(LessStylesheetAsset(self, **params, **css_params)) elif f['atype'] == 'text/css': - self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) + self.stylesheets.append(StylesheetAsset(self, **params, **css_params)) if js: if f['atype'] == 'text/javascript': - self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content'])) + self.javascripts.append(JavascriptAsset(self, **params)) elif f['atype'] == 'text/xml': - self.templates.append(XMLAsset(self, url=f['url'], filename=f['filename'], inline=f['content'])) + self.templates.append(XMLAsset(self, **params)) - def to_node(self, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False): + def to_node(self, css=True, js=True, async_load=False, defer_load=False, lazy_load=False): """ :returns [(tagName, attributes, content)] if the tag is auto close """ response = [] - is_debug_assets = debug and 'assets' in debug if css and self.stylesheets: - css_attachments = self.css(is_minified=not is_debug_assets) or [] + css_attachments = self.css(is_minified=not self.is_debug_assets) or [] for attachment in css_attachments: - if is_debug_assets: + if self.is_debug_assets: href = self.get_debug_asset_url(extra='rtl/' if self.user_direction == 'rtl' else '', name=css_attachments.name, extension='') @@ -169,7 +182,7 @@ class AssetsBundle(object): ["rel", "stylesheet"], ["href", href], ['data-asset-bundle', self.name], - ['data-asset-version', self.version], + ['data-asset-version', self.get_version('css')], ]) response.append(("link", attr, None)) if self.css_errors: @@ -187,8 +200,8 @@ class AssetsBundle(object): response.append(StylesheetAsset(self, url="/web/static/lib/bootstrap/dist/css/bootstrap.css").to_node()) if js and self.javascripts: - js_attachment = self.js(is_minified=not is_debug_assets) - src = self.get_debug_asset_url(name=js_attachment.name, extension='') if is_debug_assets else js_attachment[0].url + js_attachment = self.js(is_minified=not self.is_debug_assets) + src = self.get_debug_asset_url(name=js_attachment.name, extension='') if self.is_debug_assets else js_attachment[0].url attr = dict([ ["async", "async" if async_load else None], # lazy_load will add defer in JS otherwise this is not W3C valid @@ -197,39 +210,33 @@ class AssetsBundle(object): ["type", "text/javascript"], ["data-src" if lazy_load else "src", src], ['data-asset-bundle', self.name], - ['data-asset-version', self.version], + ['data-asset-version', self.get_version('js')], ['onerror', '__odooAssetError=1'] ]) response.append(("script", attr, None)) return response - @func.lazy_property - def last_modified_combined(self): - """Returns last modified date of linked files""" - # WebAsset are recreate here when a better solution would be to use self.stylesheets and self.javascripts - # We currently have no garanty that they are present since it will depends on js and css parameters - # last_modified is actually only usefull for the checksum and checksum should be extension specific since - # they are differents bundles. This will be a future work. + def get_version(self, asset_type): + return self.get_checksum(asset_type)[0:7] - # changing the logic from max date to combined date to fix bundle invalidation issues. - assets = [WebAsset(self, url=f['url'], filename=f['filename'], inline=f['content']) - for f in self.files - if f['atype'] in ['text/sass', "text/scss", "text/less", "text/css", "text/javascript", "text/xml"]] - return ','.join(str(asset.last_modified) for asset in assets) - - @func.lazy_property - def version(self): - return self.checksum[0:7] - - @func.lazy_property - def checksum(self): + def get_checksum(self, asset_type): """ Not really a full checksum. We compute a SHA512/256 on the rendered bundle + combined linked files last_modified date """ - check = u"%s%s" % (json.dumps(self.files, sort_keys=True), self.last_modified_combined) - return hashlib.sha512(check.encode('utf-8')).hexdigest()[:64] + if asset_type not in self._checksum_cache: + if asset_type == 'css': + assets = self.stylesheets + elif asset_type == 'js': + assets = self.javascripts + else: + raise ValueError(f'Asset type {asset_type} not known') + + unique_descriptor = ','.join(asset.unique_descriptor for asset in assets) + + self._checksum_cache[asset_type] = hashlib.sha512(unique_descriptor.encode()).hexdigest()[:64] + return self._checksum_cache[asset_type] def _get_asset_template_url(self): return "/web/assets/{id}-{unique}/{extra}{name}{sep}{extension}" @@ -277,8 +284,9 @@ class AssetsBundle(object): must exclude the current bundle. """ ira = self.env['ir.attachment'] + is_css = extension in ['css', 'min.css', 'css.map'] url = self.get_asset_url( - extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''), + extra='%s' % ('rtl/' if is_css and self.user_direction == 'rtl' else ''), name=self.name, sep='', extension='.%s' % extension @@ -286,7 +294,7 @@ class AssetsBundle(object): domain = [ ('url', '=like', url), - '!', ('url', '=like', self.get_asset_url(unique=self.version)) + '!', ('url', '=like', self.get_asset_url(unique=self.get_version('css' if is_css else 'js'))) ] attachments = ira.sudo().search(domain) # avoid to invalidate cache if it's already empty (mainly useful for test) @@ -309,14 +317,15 @@ class AssetsBundle(object): :param extension: file extension (js, min.js, css) :param ignore_version: if ignore_version, the url contains a version => web/assets/%-%/name.extension (the second '%' corresponds to the version), - else: the url contains a version equal to that of the self.version - => web/assets/%-self.version/name.extension. + else: the url contains a version equal to that of the self.get_version(type) + => web/assets/%-self.get_version(type)/name.extension. """ - unique = "%" if ignore_version else self.version + is_css = extension in ['css', 'min.css', 'css.map'] + unique = "%" if ignore_version else self.get_version('css' if is_css else 'js') url_pattern = self.get_asset_url( unique=unique, - extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''), + extra='%s' % ('rtl/' if is_css and self.user_direction == 'rtl' else ''), # not sure about css.map name=self.name, sep='', extension='.%s' % extension @@ -350,6 +359,7 @@ class AssetsBundle(object): # and allow to only clear the current direction bundle # (this applies to css bundles only) fname = '%s.%s' % (self.name, extension) + is_css = extension in ['css', 'min.css', 'css.map'] mimetype = ( 'text/css' if extension in ['css', 'min.css'] else 'text/xml' if extension in ['xml', 'min.xml'] else @@ -368,7 +378,7 @@ class AssetsBundle(object): attachment = ira.with_user(SUPERUSER_ID).create(values) url = self.get_asset_url( id=attachment.id, - unique=self.version, + unique=self.get_version('css' if is_css else 'js'), extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''), name=fname, sep='', # included in fname @@ -390,7 +400,7 @@ class AssetsBundle(object): self.env['bus.bus']._sendone('broadcast', 'bundle_changed', { 'server_version': release.version # Needs to be dynamically imported }) - _logger.debug('Asset Changed: bundle: %s -- version: %s', self.name, self.version) + _logger.debug('Asset Changed: bundle: %s -- version: %s', self.name, self.get_version('css' if is_css else 'js')) return attachment @@ -809,12 +819,13 @@ class WebAsset(object): _ir_attach = None _id = None - def __init__(self, bundle, inline=None, url=None, filename=None): + def __init__(self, bundle, inline=None, url=None, filename=None, last_modified=None): self.bundle = bundle self.inline = inline self._filename = filename self.url = url self.html_url_args = url + self._last_modified = last_modified if not inline and not url: raise Exception("An asset should either be inlined or url linked, defined in bundle '%s'" % bundle.name) @@ -823,6 +834,10 @@ class WebAsset(object): if self._id is None: self._id = str(uuid.uuid4()) return self._id + @func.lazy_property + def unique_descriptor(self): + return f'{self.url or self.inline},{self.last_modified}' + @func.lazy_property def name(self): return '' if self.inline else self.url @@ -833,11 +848,6 @@ class WebAsset(object): def stat(self): if not (self.inline or self._filename or self._ir_attach): - path = [segment for segment in self.url.split('/') if segment] - if path and path[0] != '_custom': - self._filename = get_resource_path(*path) - if self._filename: - return try: # Test url against ir.attachments self._ir_attach = self.bundle.env['ir.attachment'].sudo()._get_serve_attachment(self.url) @@ -848,17 +858,20 @@ class WebAsset(object): def to_node(self): raise NotImplementedError() - @func.lazy_property + @property def last_modified(self): - try: - self.stat() - if self._filename: - return datetime.fromtimestamp(os.path.getmtime(self._filename)) + if self._last_modified is None: + try: + self.stat() + except Exception: # most likely nor a file or an attachment, skip it + pass + if self._filename and self.bundle.is_debug_assets: # usually _last_modified should be set exept in debug=assets + self._last_modified = os.path.getmtime(self._filename) elif self._ir_attach: - return self._ir_attach.write_date - except Exception: - pass - return datetime(1970, 1, 1) + self._last_modified = self._ir_attach.write_date.timestamp() + if not self._last_modified: + self._last_modified = -1 + return self._last_modified @property def content(self): @@ -893,11 +906,15 @@ class WebAsset(object): class JavascriptAsset(WebAsset): - def __init__(self, bundle, inline=None, url=None, filename=None): - super().__init__(bundle, inline, url, filename) + def __init__(self, bundle, **kwargs): + super().__init__(bundle, **kwargs) self._is_transpiled = None self._converted_content = None + @property + def bundle_version(self): + return self.bundle.get_version('js') + @property def is_transpiled(self): if self._is_transpiled is None: @@ -928,14 +945,14 @@ class JavascriptAsset(WebAsset): ["type", "text/javascript"], ["src", self.html_url], ['data-asset-bundle', self.bundle.name], - ['data-asset-version', self.bundle.version], + ['data-asset-version', self.bundle_version], ]), None) else: return ("script", dict([ ["type", "text/javascript"], ["charset", "utf-8"], ['data-asset-bundle', self.bundle.name], - ['data-asset-version', self.bundle.version], + ['data-asset-version', self.bundle_version], ]), self.with_header()) def with_header(self, content=None, minimal=True): @@ -967,7 +984,7 @@ class XMLAsset(WebAsset): try: content = super()._fetch_content() except AssetError as e: - return f'{json.dumps(to_text(e))}' + return f'{json.dumps(to_text(e))}' parser = etree.XMLParser(ns_clean=True, recover=True, remove_comments=True) root = etree.parse(io.BytesIO(content.encode('utf-8')), parser=parser).getroot() @@ -975,6 +992,10 @@ class XMLAsset(WebAsset): return ''.join(etree.tostring(el, encoding='unicode') for el in root) return etree.tostring(root, encoding='unicode') + @property + def bundle_version(self): + return self.bundle.get_version('js') + def to_node(self): attributes = { 'async': 'async', @@ -982,7 +1003,7 @@ class XMLAsset(WebAsset): 'type': 'text/xml', 'data-src': self.html_url, 'data-asset-bundle': self.bundle.name, - 'data-asset-version': self.bundle.version, + 'data-asset-version': self.bundle_version, } return ("script", attributes, None) @@ -1017,9 +1038,9 @@ class StylesheetAsset(WebAsset): rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U) rx_charset = re.compile(r'(@charset "[^"]+";)', re.U) - def __init__(self, *args, **kw): - self.media = kw.pop('media', None) - self.direction = kw.pop('direction', None) + def __init__(self, *args, media=None, direction=None, **kw): + self.media = media + self.direction = direction super().__init__(*args, **kw) if self.direction == 'rtl' and self.url: self.html_url_args = self.url.rsplit('.', 1) @@ -1030,9 +1051,19 @@ class StylesheetAsset(WebAsset): def content(self): content = super().content if self.media: - content = '@media %s { %s }' % (self.media, content) + content = '@media %s { %s }' % (self.media, content) # this is not good! return content + @property + def bundle_version(self): + return self.bundle.get_version('css') + + @func.lazy_property + def unique_descriptor(self): + direction = self.direction or '' + media = self.media or '' + return f'{self.url or self.inline},{self.last_modified},{direction},{media}' # note that media shouldn't be used in unique since it isn't in extra + def _fetch_content(self): try: content = super()._fetch_content() @@ -1081,7 +1112,7 @@ class StylesheetAsset(WebAsset): ["href", self.html_url], ["media", escape(to_text(self.media)) if self.media else None], ['data-asset-bundle', self.bundle.name], - ['data-asset-version', self.bundle.version], + ['data-asset-version', self.bundle_version], ]) return ("link", attr, None) else: @@ -1089,7 +1120,7 @@ class StylesheetAsset(WebAsset): ["type", "text/css"], ["media", escape(to_text(self.media)) if self.media else None], ['data-asset-bundle', self.bundle.name], - ['data-asset-version', self.bundle.version], + ['data-asset-version', self.bundle_version], ]) return ("style", attr, self.with_header()) diff --git a/odoo/addons/base/models/ir_asset.py b/odoo/addons/base/models/ir_asset.py index 1ad3be23e1e..3d890061ac4 100644 --- a/odoo/addons/base/models/ir_asset.py +++ b/odoo/addons/base/models/ir_asset.py @@ -1,5 +1,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. - import os from glob import glob from logging import getLogger @@ -9,12 +8,11 @@ import odoo import odoo.modules.module # get_manifest, don't from-import it from odoo import api, fields, models, tools from odoo.tools import misc +from odoo.tools.constants import SCRIPT_EXTENSIONS, STYLE_EXTENSIONS, TEMPLATE_EXTENSIONS, ASSET_EXTENSIONS, EXTERNAL_ASSET + _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. @@ -27,7 +25,6 @@ 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): @@ -36,14 +33,21 @@ def fs2web(path): return path return '/'.join(path.split(os.path.sep)) + 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) + return '*' in path or '[' in path or ']' in path or '?' in path + + +def _glob_static_file(pattern): + files = glob(pattern, recursive=True) + return sorted((file, os.path.getmtime(file)) for file in files if file.rsplit('.', 1)[-1] in ASSET_EXTENSIONS) class IrAsset(models.Model): @@ -87,7 +91,7 @@ class IrAsset(models.Model): active = fields.Boolean(string='active', default=True) sequence = fields.Integer(string="Sequence", default=DEFAULT_SEQUENCE, required=True) - def _get_asset_paths(self, bundle, addons=None, css=False, js=False): + def _get_asset_paths(self, bundle, css=False, js=False): """ Fetches all asset file paths from a given list of addons matching a certain bundle. The returned list is composed of tuples containing the @@ -113,14 +117,21 @@ class IrAsset(models.Model): :returns: the list of tuples (path, addon, bundle) """ installed = self._get_installed_addons_list() - if addons is None: - addons = self._get_active_addons_list() + addons = self._get_active_addons_list() asset_paths = AssetPaths() - self._fill_asset_paths(bundle, addons, installed, css, js, asset_paths, []) + exts = [] + if js: + exts += SCRIPT_EXTENSIONS + exts += TEMPLATE_EXTENSIONS + if css: + exts += STYLE_EXTENSIONS + + addons = self._topological_sort(tuple(addons)) + self._fill_asset_paths(bundle, addons, installed, exts, asset_paths, []) return asset_paths.list - def _fill_asset_paths(self, bundle, addons, installed, css, js, asset_paths, seen): + def _fill_asset_paths(self, bundle, addons, installed, exts, asset_paths, seen): """ Fills the given AssetPaths instance by applying the operations found in the matching bundle of the given addons manifests. @@ -137,77 +148,74 @@ class IrAsset(models.Model): if bundle in seen: raise Exception("Circular assets bundle declaration: %s" % " > ".join(seen + [bundle])) - exts = [] - if js: - exts += SCRIPT_EXTENSIONS - exts += TEMPLATE_EXTENSIONS - if css: - exts += STYLE_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 and asset_paths. - - :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, 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) - 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') + # 1. Process the first sequence of 'ir.asset' records for asset in assets.filtered(lambda a: a.sequence < DEFAULT_SEQUENCE): - process_path(asset.directive, asset.target, asset.path) + self._process_path(bundle, asset.directive, asset.target, asset.path, addons, installed, exts, asset_paths, seen, bundle_start_index) # 2. Process all addons' manifests. - for addon in self._topological_sort(tuple(addons)): + for addon in addons: for command in odoo.modules.module.get_manifest(addon)['assets'].get(bundle, ()): directive, target, path_def = self._process_command(command) - process_path(directive, target, path_def) + self._process_path(bundle, directive, target, path_def, addons, installed, exts, asset_paths, seen, bundle_start_index) # 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.path) + self._process_path(bundle, asset.directive, asset.target, asset.path, addons, installed, exts, asset_paths, seen, bundle_start_index) + + def _process_path(self, bundle, directive, target, path_def, addons, installed, exts, asset_paths, seen, bundle_start_index): + """ + 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 and asset_paths. + + :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, exts, asset_paths, seen + [bundle]) + return + + if can_aggregate(path_def): + paths = self._get_paths(path_def, installed, exts) + else: + paths = [(path_def, EXTERNAL_ASSET, -1)] # external urls + + # 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 + if target_paths: + target = target_paths[0][0] + target_index = asset_paths.index(target, bundle) + + if directive == APPEND_DIRECTIVE: + asset_paths.append(paths, bundle) + elif directive == PREPEND_DIRECTIVE: + asset_paths.insert(paths, bundle, bundle_start_index) + elif directive == AFTER_DIRECTIVE: + asset_paths.insert(paths, bundle, target_index + 1) + elif directive == BEFORE_DIRECTIVE: + asset_paths.insert(paths, bundle, target_index) + elif directive == REMOVE_DIRECTIVE: + asset_paths.remove(paths, bundle) + elif directive == REPLACE_DIRECTIVE: + asset_paths.insert(paths, bundle, target_index) + asset_paths.remove(target_paths, bundle) + else: + # this should never happen + raise ValueError("Unexpected directive") def _get_related_assets(self, domain): """ @@ -216,6 +224,8 @@ class IrAsset(models.Model): :param domain: search domain :returns: ir.asset recordset """ + # active_test is needed to disable some assets through filter_duplicate for website + # they will be filtered on active afterward return self.with_context(active_test=False).sudo().search(domain, order='sequence, id') def _get_related_bundle(self, target_path_def, root_bundle): @@ -231,14 +241,14 @@ class IrAsset(models.Model): """ ext = target_path_def.split('.')[-1] installed = self._get_installed_addons_list() - target_path = self._get_paths(target_path_def, installed)[1][0] + target_path, _full_path, _modified = self._get_paths(target_path_def, installed)[0] css = ext in STYLE_EXTENSIONS js = ext in SCRIPT_EXTENSIONS or ext in TEMPLATE_EXTENSIONS asset_paths = self._get_asset_paths(root_bundle, css=css, js=js) - for path, _, bundle in asset_paths: + for path, _full_path, bundle, _modified in asset_paths: if path == target_path: return bundle @@ -285,21 +295,29 @@ class IrAsset(models.Model): 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 targeted by the path definition. If no file matches that glob, - the path definition is returned as is. This is either because the path is - not correctly written or because it points to a URL. + Returns a list of tuple (path, full_path, modified) matching a given glob (path_def). + The glob can only occur in the static direcory of an installed addon. + + If the path_def matches a (list of) file, the result will contain the full_path + and the modified time. + Ex: ('/base/static/file.js', '/home/user/source/odoo/odoo/addons/base/static/file.js', 643636800) + + If the path_def looks like a non aggregable path (http://, /web/assets), only return the path + Ex: ('http://example.com/lib.js', None, -1) + The timestamp -1 is given to be thruthy while carrying no information. + + If the path_def is not a wildward, but may still be a valid addons path, return a False path + with No timetamp + Ex: ('/_custom/web.asset_frontend', False, None) :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 targeted by the path definition [0] and the - list of file paths matching the definition [1] (or the glob itself if - none). Note that these paths are filtered on the given `extensions`. + :returns: a list of tuple: (path, full_path, modified) """ - paths = [] - path_url = fs2web(path_def) - path_parts = [part for part in path_url.split('/') if part] + paths = None + path_def = fs2web(path_def) # we expect to have all path definition unix style or url style, this is a safety + path_parts = [part for part in path_def.split('/') if part] addon = path_parts[0] addon_manifest = odoo.modules.module.get_manifest(addon) @@ -307,49 +325,28 @@ class IrAsset(models.Model): 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 + raise Exception(f"Unallowed to fetch files from addon {addon} for file {path_def}") + addons_path = addon_manifest['addons_path'] + full_path = os.path.normpath(os.sep.join([addons_path, *path_parts])) + # 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: + static_prefix = os.sep.join([addons_path, addon, 'static', '']) + if full_path.startswith(static_prefix): + paths_with_timestamps = _glob_static_file(full_path) paths = [ - path for path in sorted(glob(full_path, recursive=True)) + (fs2web(absolute_path[len(addons_path):]), absolute_path, timestamp) + for absolute_path, timestamp in paths_with_timestamps ] - - # 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: - # normpath will strip the trailing /, which is why it has to be added afterwards - static_path = os.path.normpath("%s/static" % addon) + os.path.sep - # Forbid xml to leak - return static_path in path - return True - - len_paths = len(paths) - paths = list(filter(is_safe_path, paths)) - safe_path = safe_path and len_paths == len(paths) - - # Web assets must be loaded using relative paths. - paths = [fs2web(path[len(addons_path):]) for path in paths] + else: + safe_path = False else: - addon = None + safe_path = False - 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 and not can_aggregate(path_def): # http:// or /web/content + paths = [(path_def, EXTERNAL_ASSET, -1)] + + if not paths and not is_wildcard_glob(path_def): # an attachment url most likely + paths = [(path_def, None, None)] if not paths: msg = f'IrAsset: the path "{path_def}" did not resolve to anything.' @@ -357,10 +354,10 @@ class IrAsset(models.Model): msg += " It may be due to security reasons." _logger.warning(msg) # Paths are filtered on the extensions (if any). - return addon, [ + return [ path for path in paths - if not extensions or path.split('.')[-1] in extensions + if not extensions or path[0].rsplit('.', maxsplit=1)[-1] in extensions ] def _process_command(self, command): @@ -382,7 +379,7 @@ class AssetPaths: self.list = [] self.memo = set() - def index(self, path, addon, bundle): + def index(self, path, 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) @@ -390,32 +387,32 @@ class AssetPaths: if asset[0] == path: return index - def append(self, paths, addon, bundle): + def append(self, paths, bundle): """Appends the given paths to the current list.""" - for path in paths: + for path, full_path, last_modified in paths: if path not in self.memo: - self.list.append((path, addon, bundle)) + self.list.append((path, full_path, bundle, last_modified)) self.memo.add(path) - def insert(self, paths, addon, bundle, index): + def insert(self, paths, bundle, index): """Inserts the given paths to the current list at the given position.""" to_insert = [] - for path in paths: + for path, full_path, last_modified in paths: if path not in self.memo: - to_insert.append((path, addon, bundle)) + to_insert.append((path, full_path, bundle, last_modified)) self.memo.add(path) self.list[index:index] = to_insert - def remove(self, paths_to_remove, addon, bundle): + def remove(self, paths_to_remove, bundle): """Removes the given paths from the current list.""" - paths = {path for path in paths_to_remove if path in self.memo} + paths = {path for path, _full_path, _last_modified in paths_to_remove 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 paths_to_remove: - self._raise_not_found(paths_to_remove, bundle) + self._raise_not_found([path for path, _full_path, _last_modified in paths_to_remove], bundle) def _raise_not_found(self, path, bundle): raise ValueError("File(s) %s not found in bundle %s" % (path, bundle)) diff --git a/odoo/addons/base/models/ir_qweb.py b/odoo/addons/base/models/ir_qweb.py index 8b7a3e4e155..624dba6c375 100644 --- a/odoo/addons/base/models/ir_qweb.py +++ b/odoo/addons/base/models/ir_qweb.py @@ -379,18 +379,18 @@ from dateutil.relativedelta import relativedelta from psycopg2.extensions import TransactionRollbackError from odoo import api, models, tools -from odoo.tools import config, safe_eval, pycompat, SUPPORTED_DEBUGGER +from odoo.tools import config, safe_eval, pycompat +from odoo.tools.constants import SUPPORTED_DEBUGGER, EXTERNAL_ASSET from odoo.tools.safe_eval import assert_valid_codeobj, _BUILTINS, to_opcodes, _EXPR_OPCODES, _BLACKLIST from odoo.tools.json import scriptsafe from odoo.tools.misc import str2bool from odoo.tools.image import image_data_uri from odoo.http import request -from odoo.modules.module import get_resource_path, get_module_path from odoo.tools.profiler import QwebTracker from odoo.exceptions import UserError, AccessDenied, AccessError, MissingError, ValidationError from odoo.addons.base.models.assetsbundle import AssetsBundle -from odoo.addons.base.models.ir_asset import can_aggregate, STYLE_EXTENSIONS, SCRIPT_EXTENSIONS, TEMPLATE_EXTENSIONS +from odoo.tools.constants import SCRIPT_EXTENSIONS, STYLE_EXTENSIONS, TEMPLATE_EXTENSIONS _logger = logging.getLogger(__name__) @@ -2472,22 +2472,16 @@ class IrQWeb(models.AbstractModel): @tools.ormcache('bundle', 'defer_load', 'lazy_load', 'media', 'tuple(self.env.context.get(k) for k in self._get_template_cache_keys())') def _get_asset_content(self, bundle, defer_load=False, lazy_load=False, media=None): asset_paths = self.env['ir.asset']._get_asset_paths(bundle=bundle, css=True, js=True) - files = [] remains = [] - for path, *_ in asset_paths: - ext = path.split('.')[-1] + for path, full_path, _bundle, last_modified in asset_paths: + ext = path.rpartition('.')[2] is_js = ext in SCRIPT_EXTENSIONS is_xml = ext in TEMPLATE_EXTENSIONS is_css = ext in STYLE_EXTENSIONS if not is_js and not is_xml and not is_css: continue - if is_xml: - base = get_module_path(bundle.split('.')[0]).rsplit('/', 1)[0] - if path.startswith(base): - path = path[len(base):] - mimetype = None if is_js: mimetype = 'text/javascript' @@ -2496,14 +2490,14 @@ class IrQWeb(models.AbstractModel): elif is_xml: mimetype = 'text/xml' - if can_aggregate(path): - segments = [segment for segment in path.split('/') if segment] + if full_path is not EXTERNAL_ASSET: files.append({ 'atype': mimetype, 'url': path, - 'filename': get_resource_path(*segments) if segments and segments[0] != '_custom' else None, + 'filename': full_path, 'content': '', 'media': media, + 'last_modified': last_modified, }) else: if is_js: @@ -2536,14 +2530,14 @@ class IrQWeb(models.AbstractModel): return (files, remains) - 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_bundle(self, bundle_name, files, env=None, css=True, js=True, debug=False): + return AssetsBundle(bundle_name, files, env=env, css=css, js=js, debug=debug) def _generate_asset_nodes(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None): files, remains = self._get_asset_content(bundle, defer_load=defer_load, lazy_load=lazy_load, media=css and media or None) - asset = self._get_asset_bundle(bundle, files, env=self.env, css=css, js=js) + asset = self._get_asset_bundle(bundle, files, env=self.env, css=css, js=js, debug=debug) 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) + return remains + asset.to_node(css=css, js=js, async_load=async_load, defer_load=defer_load, lazy_load=lazy_load) def _get_asset_link_urls(self, bundle, debug=False): asset_nodes = self._get_asset_nodes(bundle, js=False, debug=debug) diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py index 5c8a96ca0aa..1217dace5c6 100644 --- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py +++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py @@ -33,50 +33,68 @@ class TestAddonPaths(TransactionCase): asset_paths = AssetPaths() self.assertFalse(asset_paths.list) - asset_paths.append(['a', 'c', 'd'], 'module1', 'bundle1') + asset_paths.append([ + ('/home/user/odoo/addons/web/a', '/web/a', 1), + ('/home/user/odoo/addons/web/c', '/web/c', 1), + ('/home/user/odoo/addons/web/d', '/web/d', 1), + ], 'bundle1') self.assertEqual(asset_paths.list, [ - ('a', 'module1', 'bundle1'), - ('c', 'module1', 'bundle1'), - ('d', 'module1', 'bundle1'), + ('/home/user/odoo/addons/web/a', '/web/a', 'bundle1', 1), + ('/home/user/odoo/addons/web/c', '/web/c', 'bundle1', 1), + ('/home/user/odoo/addons/web/d', '/web/d', 'bundle1', 1), ]) # append with a duplicate of 'c' - asset_paths.append(['c', 'f'], 'module2', 'bundle2') + asset_paths.append([ + ('/home/user/odoo/addons/web/c', '/web/c', 1), + ('/home/user/odoo/addons/web/f', '/web/f', 1), + ], 'bundle2') self.assertEqual(asset_paths.list, [ - ('a', 'module1', 'bundle1'), - ('c', 'module1', 'bundle1'), - ('d', 'module1', 'bundle1'), - ('f', 'module2', 'bundle2'), + ('/home/user/odoo/addons/web/a', '/web/a', 'bundle1', 1), + ('/home/user/odoo/addons/web/c', '/web/c', 'bundle1', 1), + ('/home/user/odoo/addons/web/d', '/web/d', 'bundle1', 1), + ('/home/user/odoo/addons/web/f', '/web/f', 'bundle2', 1), ]) # insert with a duplicate of 'c' after 'c' - asset_paths.insert(['c', 'e'], 'module3', 'bundle3', 3) + asset_paths.insert([ + ('/home/user/odoo/addons/web/c', '/web/c', 1), + ('/home/user/odoo/addons/web/e', '/web/e', 1), + ], 'bundle3', 3) self.assertEqual(asset_paths.list, [ - ('a', 'module1', 'bundle1'), - ('c', 'module1', 'bundle1'), - ('d', 'module1', 'bundle1'), - ('e', 'module3', 'bundle3'), - ('f', 'module2', 'bundle2'), + ('/home/user/odoo/addons/web/a', '/web/a', 'bundle1', 1), + ('/home/user/odoo/addons/web/c', '/web/c', 'bundle1', 1), + ('/home/user/odoo/addons/web/d', '/web/d', 'bundle1', 1), + ('/home/user/odoo/addons/web/e', '/web/e', 'bundle3', 1), + ('/home/user/odoo/addons/web/f', '/web/f', 'bundle2', 1), ]) # insert with a duplicate of 'd' before 'd' - asset_paths.insert(['b', 'd'], 'module4', 'bundle4', 1) + asset_paths.insert([ + ('/home/user/odoo/addons/web/b', '/web/b', 1), + ('/home/user/odoo/addons/web/d', '/web/d', 1) + ], 'bundle4', 1) self.assertEqual(asset_paths.list, [ - ('a', 'module1', 'bundle1'), - ('b', 'module4', 'bundle4'), - ('c', 'module1', 'bundle1'), - ('d', 'module1', 'bundle1'), - ('e', 'module3', 'bundle3'), - ('f', 'module2', 'bundle2'), + + ('/home/user/odoo/addons/web/a', '/web/a', 'bundle1', 1), + ('/home/user/odoo/addons/web/b', '/web/b', 'bundle4', 1), + ('/home/user/odoo/addons/web/c', '/web/c', 'bundle1', 1), + ('/home/user/odoo/addons/web/d', '/web/d', 'bundle1', 1), + ('/home/user/odoo/addons/web/e', '/web/e', 'bundle3', 1), + ('/home/user/odoo/addons/web/f', '/web/f', 'bundle2', 1), ]) # remove - asset_paths.remove(['c', 'd', 'g'], 'module5', 'bundle5') + asset_paths.remove([ + ('/home/user/odoo/addons/web/c', '/web/c', 1), + ('/home/user/odoo/addons/web/d', '/web/d', 1), + ('/home/user/odoo/addons/web/g', '/web/g', 1) + ], 'bundle5') self.assertEqual(asset_paths.list, [ - ('a', 'module1', 'bundle1'), - ('b', 'module4', 'bundle4'), - ('e', 'module3', 'bundle3'), - ('f', 'module2', 'bundle2'), + ('/home/user/odoo/addons/web/a', '/web/a', 'bundle1', 1), + ('/home/user/odoo/addons/web/b', '/web/b', 'bundle4', 1), + ('/home/user/odoo/addons/web/e', '/web/e', 'bundle3', 1), + ('/home/user/odoo/addons/web/f', '/web/f', 'bundle2', 1), ]) @@ -106,16 +124,22 @@ class FileTouchable(AddonManifestPatched): class TestJavascriptAssetsBundle(FileTouchable): + @classmethod + def setUpClass(cls): + super().setUpClass() + # this is mainly to avoid tests breaking when executed after pre-generate + cls.env['ir.attachment'].search([('url', '=like', '/web/assets/%test_assetsbundle%')]).unlink() + def setUp(self): super(TestJavascriptAssetsBundle, self).setUp() self.jsbundle_name = 'test_assetsbundle.bundle1' self.cssbundle_name = 'test_assetsbundle.bundle2' self.env['res.lang']._activate_lang('ar_SY') - def _get_asset(self, bundle, env=None): + def _get_asset(self, bundle, env=None, debug=''): env = (env or self.env) files, _ = env['ir.qweb']._get_asset_content(bundle) - return AssetsBundle(bundle, files, env=env) + return AssetsBundle(bundle, files, env=env, debug=debug) def _any_ira_for_bundle(self, extension, lang=None): """ Returns all ir.attachments associated to a bundle, regardless of the verion. @@ -180,7 +204,7 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, "there should be one minified attachment associated to this bundle") - version0 = bundle0.version + version0 = bundle0.get_version('js') ira0 = self._any_ira_for_bundle('min.js') date0 = ira0.create_date @@ -190,7 +214,7 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, "there should be one minified attachment associated to this bundle") - version1 = bundle1.version + version1 = bundle1.get_version('js') ira1 = self._any_ira_for_bundle('min.js') date1 = ira1.create_date @@ -202,18 +226,18 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_03_date_invalidation(self): """ Checks that a bundle is invalidated when one of its assets' modification date is changed. """ - bundle0 = self._get_asset(self.jsbundle_name) + bundle0 = self._get_asset(self.jsbundle_name, debug="assets") bundle0.js() - last_modified0 = bundle0.last_modified_combined - version0 = bundle0.version + last_modified0 = bundle0.get_checksum('js') + version0 = bundle0.get_version('js') path = get_resource_path('test_assetsbundle', 'static', 'src', 'js', 'test_jsfile1.js') - bundle1 = self._get_asset(self.jsbundle_name) + bundle1 = self._get_asset(self.jsbundle_name, debug="assets") with self._touch(path): bundle1.js() - last_modified1 = bundle1.last_modified_combined - version1 = bundle1.version + last_modified1 = bundle1.get_checksum('js') + version1 = bundle1.get_version('js') self.assertNotEqual(last_modified0, last_modified1, "the creation date of the ir.attachment should change because the bundle has changed.") self.assertNotEqual(version0, version1, @@ -230,7 +254,7 @@ class TestJavascriptAssetsBundle(FileTouchable): bundle0 = self._get_asset(self.jsbundle_name) bundle0.js() files0 = bundle0.files - version0 = bundle0.version + version0 = bundle0.get_version('js') self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, "there should be one minified attachment associated to this bundle") @@ -244,7 +268,7 @@ class TestJavascriptAssetsBundle(FileTouchable): bundle1 = self._get_asset(self.jsbundle_name) bundle1.js() files1 = bundle1.files - version1 = bundle1.version + version1 = bundle1.get_version('js') self.assertNotEqual(files0, files1, "the list of files should be different because a file has been added to the bundle") @@ -277,8 +301,8 @@ class TestJavascriptAssetsBundle(FileTouchable): """ Checks that a bundle rendered in debug 1 mode outputs non-minified assets and create an non-minified ir.attachment. """ - debug_bundle = self._get_asset(self.jsbundle_name) - nodes = debug_bundle.to_node(debug='1') + debug_bundle = self._get_asset(self.jsbundle_name, debug='1') + nodes = debug_bundle.to_node() content = self._node_to_list(nodes) # there should be a minified file self.assertEqual(content[3].count('test_assetsbundle.bundle1.min.js'), 1, @@ -296,8 +320,8 @@ class TestJavascriptAssetsBundle(FileTouchable): """ Checks that a bundle rendered in debug assets mode outputs non-minified assets and create an non-minified ir.attachment at the . """ - debug_bundle = self._get_asset(self.jsbundle_name) - nodes = debug_bundle.to_node(debug='assets') + debug_bundle = self._get_asset(self.jsbundle_name, debug='assets') + nodes = debug_bundle.to_node() content = self._node_to_list(nodes) # there should be a non-minified file (not .min.js) self.assertEqual(content[3].count('test_assetsbundle.bundle1.js'), 1, @@ -327,7 +351,7 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.css')), 1) - version0 = bundle0.version + version0 = bundle0.get_version('css') ira0 = self._any_ira_for_bundle('min.css') date0 = ira0.create_date @@ -336,7 +360,7 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.css')), 1) - version1 = bundle1.version + version1 = bundle1.get_version('css') ira1 = self._any_ira_for_bundle('min.css') date1 = ira1.create_date @@ -350,7 +374,7 @@ class TestJavascriptAssetsBundle(FileTouchable): bundle0 = self._get_asset(self.cssbundle_name) bundle0.css() files0 = bundle0.files - version0 = bundle0.version + version0 = bundle0.get_version('css') self.assertEqual(len(self._any_ira_for_bundle('min.css')), 1) @@ -363,7 +387,7 @@ class TestJavascriptAssetsBundle(FileTouchable): bundle1 = self._get_asset(self.cssbundle_name) bundle1.css() files1 = bundle1.files - version1 = bundle1.version + version1 = bundle1.get_version('css') self.assertNotEqual(files0, files1) self.assertNotEqual(version0, version1) @@ -374,8 +398,8 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_12_css_debug(self): """ Check that a bundle in debug mode outputs non-minified assets. """ - debug_bundle = self._get_asset(self.cssbundle_name) - nodes = debug_bundle.to_node(debug='assets') + debug_bundle = self._get_asset(self.cssbundle_name, debug='assets') + nodes = debug_bundle.to_node() content = self._node_to_list(nodes) # find back one of the original asset file self.assertIn('/web/assets/debug/test_assetsbundle.bundle2.css', content) @@ -433,7 +457,7 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.css')), 1) - ltr_version0 = ltr_bundle0.version + ltr_version0 = ltr_bundle0.get_version('css') ltr_ira0 = self._any_ira_for_bundle('min.css') ltr_date0 = ltr_ira0.create_date @@ -442,7 +466,7 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.css')), 1) - ltr_version1 = ltr_bundle1.version + ltr_version1 = ltr_bundle1.get_version('css') ltr_ira1 = self._any_ira_for_bundle('min.css') ltr_date1 = ltr_ira1.create_date @@ -455,7 +479,7 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.css', lang='ar_SY')), 1) - rtl_version0 = rtl_bundle0.version + rtl_version0 = rtl_bundle0.get_version('css') rtl_ira0 = self._any_ira_for_bundle('min.css', lang='ar_SY') rtl_date0 = rtl_ira0.create_date @@ -464,7 +488,7 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('min.css', lang='ar_SY')), 1) - rtl_version1 = rtl_bundle1.version + rtl_version1 = rtl_bundle1.get_version('css') rtl_ira1 = self._any_ira_for_bundle('min.css', lang='ar_SY') rtl_date1 = rtl_ira1.create_date @@ -484,35 +508,35 @@ class TestJavascriptAssetsBundle(FileTouchable): """ Checks that both css bundles are invalidated when one of its assets' modification date is changed """ # Assets access for en_US language - ltr_bundle0 = self._get_asset(self.cssbundle_name) + ltr_bundle0 = self._get_asset(self.cssbundle_name, debug='assets') ltr_bundle0.css() - ltr_last_modified0 = ltr_bundle0.last_modified_combined - ltr_version0 = ltr_bundle0.version + ltr_last_modified0 = ltr_bundle0.get_checksum('css') + ltr_version0 = ltr_bundle0.get_version('css') # Assets access for ar_SY language - rtl_bundle0 = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'})) + rtl_bundle0 = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'}), debug='assets') rtl_bundle0.css() - rtl_last_modified0 = rtl_bundle0.last_modified_combined - rtl_version0 = rtl_bundle0.version + rtl_last_modified0 = rtl_bundle0.get_checksum('css') + rtl_version0 = rtl_bundle0.get_version('css') # Touch test_cssfile1.css # Note: No lang specific context given while calling _get_asset so it will load assets for en_US path = get_resource_path('test_assetsbundle', 'static', 'src', 'css', 'test_cssfile1.css') - ltr_bundle1 = self._get_asset(self.cssbundle_name) + ltr_bundle1 = self._get_asset(self.cssbundle_name, debug='assets') with self._touch(path): ltr_bundle1.css() - ltr_last_modified1 = ltr_bundle1.last_modified_combined - ltr_version1 = ltr_bundle1.version + ltr_last_modified1 = ltr_bundle1.get_checksum('css') + ltr_version1 = ltr_bundle1.get_version('css') ltr_ira1 = self._any_ira_for_bundle('min.css') self.assertNotEqual(ltr_last_modified0, ltr_last_modified1) self.assertNotEqual(ltr_version0, ltr_version1) - rtl_bundle1 = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'})) + rtl_bundle1 = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'}), debug='assets') rtl_bundle1.css() - rtl_last_modified1 = rtl_bundle1.last_modified_combined - rtl_version1 = rtl_bundle1.version + rtl_last_modified1 = rtl_bundle1.get_checksum('css') + rtl_version1 = rtl_bundle1.get_version('css') rtl_ira1 = self._any_ira_for_bundle('min.css', lang='ar_SY') self.assertNotEqual(rtl_last_modified0, rtl_last_modified1) self.assertNotEqual(rtl_version0, rtl_version1) @@ -534,12 +558,12 @@ class TestJavascriptAssetsBundle(FileTouchable): ltr_bundle0 = self._get_asset(self.cssbundle_name) ltr_bundle0.css() ltr_files0 = ltr_bundle0.files - ltr_version0 = ltr_bundle0.version + ltr_version0 = ltr_bundle0.get_version('css') rtl_bundle0 = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'})) rtl_bundle0.css() rtl_files0 = rtl_bundle0.files - rtl_version0 = rtl_bundle0.version + rtl_version0 = rtl_bundle0.get_version('css') css_bundles = self.env['ir.attachment'].search([ ('url', '=like', '/web/assets/%-%/{0}%.{1}'.format(self.cssbundle_name, 'min.css')) @@ -555,7 +579,7 @@ class TestJavascriptAssetsBundle(FileTouchable): ltr_bundle1 = self._get_asset(self.cssbundle_name) ltr_bundle1.css() ltr_files1 = ltr_bundle1.files - ltr_version1 = ltr_bundle1.version + ltr_version1 = ltr_bundle1.get_version('css') ltr_ira1 = self._any_ira_for_bundle('min.css') self.assertNotEqual(ltr_files0, ltr_files1) @@ -564,7 +588,7 @@ class TestJavascriptAssetsBundle(FileTouchable): rtl_bundle1 = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'})) rtl_bundle1.css() rtl_files1 = rtl_bundle1.files - rtl_version1 = rtl_bundle1.version + rtl_version1 = rtl_bundle1.get_version('css') rtl_ira1 = self._any_ira_for_bundle('min.css', lang='ar_SY') self.assertNotEqual(rtl_files0, rtl_files1) @@ -582,8 +606,8 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_19_css_in_debug_assets(self): """ Checks that a bundle rendered in debug mode(assets) with right to left language direction stores css files in assets bundle. """ - debug_bundle = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'})) - nodes = debug_bundle.to_node(debug='assets') + debug_bundle = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'}), debug='assets') + nodes = debug_bundle.to_node() content = self._node_to_list(nodes) # there should be an css assets bundle in /debug/rtl if user's lang direction is rtl and debug=assets @@ -751,24 +775,32 @@ class TestAssetsBundleWithIRAMock(FileTouchable): self.patch(AssetsBundle, '_unlink_attachments', unlink) def _get_asset(self): - files, _ = self.env['ir.qweb']._get_asset_content(self.stylebundle_name) - return AssetsBundle(self.stylebundle_name, files, env=self.env) + with patch.object(type(self.env['ir.asset']), '_get_installed_addons_list', Mock(return_value=self.installed_modules)): + files, _ = self.env['ir.qweb']._get_asset_content(self.stylebundle_name) + return AssetsBundle(self.stylebundle_name, files, env=self.env, debug='assets') - def _bundle(self, asset, should_create, should_unlink): + def _bundle(self, asset, should_create, should_unlink, reason=''): self.counter.clear() - asset.to_node(debug='assets') - self.assertEqual(self.counter['create'], 2 if should_create else 0) - self.assertEqual(self.counter['unlink'], 2 if should_unlink else 0) + asset.to_node() + if should_create: + self.assertEqual(self.counter['create'], 2, f'An attachment should have been created {reason}') + else: + self.assertEqual(self.counter['create'], 0, f'No attachment should have been created {reason}') + + if should_unlink: + self.assertEqual(self.counter['unlink'], 2, f'An attachment should have been unlink {reason}') + else: + self.assertEqual(self.counter['unlink'], 0, f'No attachment should have been unlink {reason}') def test_01_debug_mode_assets(self): """ Checks that the ir.attachments records created for compiled assets in debug mode are correctly invalidated. """ # Compile for the first time - self._bundle(self._get_asset(), True, False) + self._bundle(self._get_asset(), True, False, '(First access)') # Compile a second time, without changes - self._bundle(self._get_asset(), False, False) + self._bundle(self._get_asset(), False, False, '(Second access, no change)') # Touch the file and compile a third time path = get_resource_path('test_assetsbundle', 'static', 'src', 'scss', 'test_file1.scss') @@ -1065,7 +1097,7 @@ class TestAssetsManifest(AddonManifestPatched): 'name': 'test_jsfile4', 'bundle': 'test_assetsbundle.manifest2', 'directive': 'remove', - 'path': 'test_assetsbundle/static/src/**/*', + 'path': 'test_assetsbundle/static/src/*/**', }) self.env['ir.qweb']._render(view.id) attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.manifest2.js')], order='create_date DESC', limit=1) @@ -1758,10 +1790,10 @@ class TestAssetsManifest(AddonManifestPatched): view = self.make_asset_view('test_assetsbundle.irassetsec') self.env['ir.qweb']._render(view.id) attach = self.env['ir.attachment'].search([('name', 'ilike', 'test_assetsbundle.irassetsec')], order='create_date DESC', limit=1) - self.assertFalse(attach.exists()) + self.assertIn(b"Could not get content for /test_assetsbundle/../../tests/dummy.js", attach.exists().raw) @mute_logger('odoo.addons.base.models.ir_asset') - def test_32(self): + def test_32_a_relative_path_in_addon(self): path_to_dummy = '../../tests/dummy.xml' me = pathlib.Path(__file__).parent.absolute() file_path = me.joinpath("..", path_to_dummy) # assuming me = test_assetsbundle/tests @@ -1773,8 +1805,25 @@ class TestAssetsManifest(AddonManifestPatched): 'path': '/test_assetsbundle/%s' % path_to_dummy, }) - files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules)) - self.assertFalse(files) + files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec') + self.assertEqual(files, [('/test_assetsbundle/../../tests/dummy.xml', None, 'test_assetsbundle.irassetsec', None)]) + # TODO, validate this behaviour + # the idea is that if the second element is False (not None) it will be added to the assetbundle, but considered in any case as an attachment url) + + @mute_logger('odoo.addons.base.models.ir_asset') + def test_32_b_relative_path_outsied_addon(self): + path_to_dummy = '../../tests/dummy.xml' + me = pathlib.Path(__file__).parent.absolute() + file_path = me.joinpath("..", path_to_dummy) # assuming me = test_assetsbundle/tests + self.assertTrue(os.path.isfile(file_path)) + + self.env['ir.asset'].create({ + 'name': '1', + 'bundle': 'test_assetsbundle.irassetsec', + 'path': '%s' % path_to_dummy, + }) + files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec') + self.assertEqual(files, [('../../tests/dummy.xml', None, 'test_assetsbundle.irassetsec', None)]) def test_33(self): self.manifests['notinstalled_module'] = { @@ -1822,8 +1871,8 @@ class TestAssetsManifest(AddonManifestPatched): 'bundle': 'test_assetsbundle.irassetsec', 'path': '/test_assetsbundle/data/ir_asset.xml', }) - files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules)) - self.assertFalse(files) + files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec') + self.assertEqual(files, [('/test_assetsbundle/data/ir_asset.xml', None, 'test_assetsbundle.irassetsec', None)]) def test_36(self): self.env['ir.asset'].create({ @@ -1831,9 +1880,17 @@ class TestAssetsManifest(AddonManifestPatched): 'bundle': 'test_assetsbundle.irassetsec', 'path': '/test_assetsbundle/static/accessible.xml', }) - files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', addons=list(self.installed_modules)) - self.assertEqual(len(files), 1) - self.assertTrue('test_assetsbundle/static/accessible.xml' in files[0][0]) + files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec') + modified = files[0][3] + + base_path = __file__.replace('/tests/test_assetsbundle.py', '') + + self.assertEqual(files, [( + '/test_assetsbundle/static/accessible.xml', + f'{base_path}/static/accessible.xml', + 'test_assetsbundle.irassetsec', + modified + )]) def test_37_path_can_be_an_attachment(self): scss_code = base64.b64encode(b""" diff --git a/odoo/addons/test_lint/tests/test_pylint.py b/odoo/addons/test_lint/tests/test_pylint.py index b436b24e1f6..04f00016e20 100644 --- a/odoo/addons/test_lint/tests/test_pylint.py +++ b/odoo/addons/test_lint/tests/test_pylint.py @@ -42,7 +42,7 @@ class TestPyLint(TransactionCase): 'csv', 'urllib', 'cgi', - ] + list(tools.SUPPORTED_DEBUGGER) + ] + list(tools.constants.SUPPORTED_DEBUGGER) def _skip_test(self, reason): _logger.warning(reason) diff --git a/odoo/tools/__init__.py b/odoo/tools/__init__.py index f08fc9bf412..21587bffdd2 100644 --- a/odoo/tools/__init__.py +++ b/odoo/tools/__init__.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -SUPPORTED_DEBUGGER = {'pdb', 'ipdb', 'wdb', 'pudb'} from . import _monkeypatches from . import appdirs from . import cloc +from . import constants from . import pdf from . import pycompat from . import win32 diff --git a/odoo/tools/constants.py b/odoo/tools/constants.py new file mode 100644 index 00000000000..89d246a28f6 --- /dev/null +++ b/odoo/tools/constants.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +SCRIPT_EXTENSIONS = ('js',) +STYLE_EXTENSIONS = ('css', 'scss', 'sass', 'less') +TEMPLATE_EXTENSIONS = ('xml',) +ASSET_EXTENSIONS = SCRIPT_EXTENSIONS + STYLE_EXTENSIONS + TEMPLATE_EXTENSIONS + +SUPPORTED_DEBUGGER = {'pdb', 'ipdb', 'wdb', 'pudb'} +EXTERNAL_ASSET = object()