diff --git a/addons/bus/controllers/websocket.py b/addons/bus/controllers/websocket.py index 8c828094fd7..e97871ff120 100644 --- a/addons/bus/controllers/websocket.py +++ b/addons/bus/controllers/websocket.py @@ -64,8 +64,9 @@ class WebsocketController(Controller): prevent new worker versions to be loaded from the browser cache. """ bundle = 'bus.websocket_worker_assets' - files, _ = request.env["ir.qweb"]._get_asset_content(bundle) - asset = AssetsBundle(bundle, files) + assets_params = request.env["ir.asset"]._get_assets_params() + files, _ = request.env["ir.qweb"]._get_asset_content(bundle, assets_params=assets_params) + asset = AssetsBundle(bundle, files, assets_params=assets_params) stream = request.env['ir.binary']._get_stream_from(asset.js( is_minified="assets" not in request.session.debug )) diff --git a/addons/test_website/tests/test_qweb.py b/addons/test_website/tests/test_qweb.py index 6f165f50d6f..c66615a6b02 100644 --- a/addons/test_website/tests/test_qweb.py +++ b/addons/test_website/tests/test_qweb.py @@ -65,11 +65,11 @@ class TestQweb(TransactionCaseWithUserDemo): - + - + diff --git a/addons/web/tests/test_assets.py b/addons/web/tests/test_assets.py index b4261b2b917..dd2c4349148 100644 --- a/addons/web/tests/test_assets.py +++ b/addons/web/tests/test_assets.py @@ -34,7 +34,7 @@ class TestAssetsGenerateTimeCommon(odoo.tests.TransactionCase): start_t = time.time() css = assets_type == 'css' js = assets_type == 'js' - self.env['ir.qweb']._generate_asset_nodes(bundle, css=css, js=js) + self.env['ir.qweb']._generate_asset_links(bundle, css=css, js=js) yield (f'{bundle}.{assets_type}', time.time() - start_t) except ValueError: _logger.info('Error detected while generating bundle %r %s', bundle, assets_type) diff --git a/addons/web/tests/test_assets_xml.py b/addons/web/tests/test_assets_xml.py index be1f995d583..fe46a970a7a 100644 --- a/addons/web/tests/test_assets_xml.py +++ b/addons/web/tests/test_assets_xml.py @@ -63,19 +63,13 @@ class TestStaticInheritanceCommon(odoo.tests.TransactionCase): def renderBundle(self, debug=False): files = [] for url in self.template_files: - atype = 'text/xml' - if '.js' in url: - atype = 'text/javascript' files.append({ - 'atype': atype, 'url': url, 'filename': url, 'content': None, 'media': None, }) - asset = AssetsBundle('web.test_bundle', files, env=self.env, css=False, js=True, debug='assets' if debug else '') - # to_node return the files descriptions and generate attachments. - asset.to_node(css=False, js=False) + asset = AssetsBundle('web.test_bundle', files, env=self.env, css=False, js=True, debug_assets=debug) content = asset.xml(show_inherit_info=debug) return f'\n{content}\n' diff --git a/addons/web/tests/test_js.py b/addons/web/tests/test_js.py index c30cc17203b..4cef8ec460c 100644 --- a/addons/web/tests/test_js.py +++ b/addons/web/tests/test_js.py @@ -45,7 +45,7 @@ class WebSuite(odoo.tests.HttpCase): for asset in assets: filename = asset['filename'] - if not filename or asset['atype'] != 'text/javascript': + if not filename or not filename.endswith('.js'): continue with open(filename, 'rb') as fp: if RE_ONLY.search(fp.read().decode('utf-8')): diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 967648991d8..c0a98a41d10 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -448,9 +448,9 @@ class Web_Editor(http.Controller): AssetsUtils = request.env['web_editor.assets'] files_data_by_bundle = [] - resources_type_info = {'t_call_assets_attribute': 't-js', 'mimetype': 'text/javascript'} + t_call_assets_attribute = 't-js' if file_type == 'scss': - resources_type_info = {'t_call_assets_attribute': 't-css', 'mimetype': 'text/scss'} + t_call_assets_attribute = 't-css' # Compile regex outside of the loop # This will used to exclude library scss files from the result @@ -460,7 +460,7 @@ 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]"): - attr = asset_call_node.get(resources_type_info['t_call_assets_attribute']) + attr = asset_call_node.get(t_call_assets_attribute) if attr and not json.loads(attr.lower()): continue asset_name = asset_call_node.get("t-call-assets") @@ -468,7 +468,7 @@ class Web_Editor(http.Controller): # Loop through bundle files to search for file info files_data = [] for file_info in request.env["ir.qweb"]._get_asset_content(asset_name)[0]: - if file_info["atype"] != resources_type_info['mimetype']: + if file_info["url"].rpartition('.')[2] != file_type: continue url = file_info["url"] @@ -608,7 +608,8 @@ class Web_Editor(http.Controller): if not bundle_css: bundle = 'web.assets_frontend' files, _ = request.env["ir.qweb"]._get_asset_content(bundle) - asset = AssetsBundle(bundle, files) + assets_params = request.env["ir.asset"]._get_assets_params() + asset = AssetsBundle(bundle, files, assets_params=assets_params) bundle_css = asset.css().index_content color_search = re.search(r'(?i)--%s:\s+(%s|%s)' % (css_color_value, regex_hex, regex_rgba), bundle_css) if not color_search: diff --git a/addons/website/models/ir_asset.py b/addons/website/models/ir_asset.py index f0d6adfada4..889d7ce9a47 100644 --- a/addons/website/models/ir_asset.py +++ b/addons/website/models/ir_asset.py @@ -10,35 +10,41 @@ class IrAsset(models.Model): key = fields.Char(copy=False) # used to resolve multiple assets in a multi-website environment website_id = fields.Many2one('website', ondelete='cascade') - 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_assets_params(self): + params = super()._get_assets_params() + params['website_id'] = self.env['website'].get_current_website(fallback=False).id + return params - def _get_active_addons_list(self): + def _get_related_assets(self, domain, website_id=None, **params): + if website_id: + domain += self.env['website'].website_domain(website_id) + assets = super()._get_related_assets(domain, **params) + return assets.filter_duplicate(website_id) + + def _get_active_addons_list(self, website_id=None, **params): """Overridden to discard inactive themes.""" - addons_list = super()._get_active_addons_list() - website = self.env['website'].get_current_website(fallback=False) + addons_list = super()._get_active_addons_list(**params) - if not website: + if not website_id: 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 + themes = IrModule.search(IrModule.get_themes_domain()) - self.env["website"].browse(website_id).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): + def filter_duplicate(self, website_id=None): """ 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 website_id is not None: + current_website = self.env['website'].browse(website_id) + else: + current_website = self.env['website'].get_current_website(fallback=False) if not current_website: return self.filtered(lambda asset: not asset.website_id) diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index 476c3f8e3c3..87e04678a11 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -20,14 +20,14 @@ re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^ class AssetsBundleMultiWebsite(AssetsBundle): def _get_asset_url_values(self, id, unique, extra, name, sep, extension): - website_id = self.env.context.get('website_id') + website_id = self.assets_params.get('website_id') website_id_path = website_id and ('%s/' % website_id) or '' extra = website_id_path + extra res = super(AssetsBundleMultiWebsite, self)._get_asset_url_values(id, unique, extra, name, sep, extension) return res def get_debug_asset_url(self, extra='', name='%', extension='%'): - website_id = self.env.context.get('website_id') + website_id = self.assets_params.get('website_id') website_id_path = website_id and ('%s/' % website_id) or '' extra = website_id_path + extra return super(AssetsBundleMultiWebsite, self).get_debug_asset_url(extra, name, extension) @@ -106,8 +106,8 @@ class IrQWeb(models.AbstractModel): return irQweb - 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 _get_asset_bundle(self, xmlid, files=None, env=None, css=True, js=True, debug_assets=False, rtl=False, assets_params=None): + return AssetsBundleMultiWebsite(xmlid, files, env=env, css=css, js=js, debug_assets=debug_assets, rtl=rtl, assets_params=assets_params) def _post_processing_att(self, tagName, atts): if atts.get('data-no-post-process'): @@ -161,14 +161,13 @@ class IrQWeb(models.AbstractModel): # version part combine with the initial extra (rtl) should be enough to ensure they are identical. # we dont expect to have any pregenerated rtl/website attachment so we don't manage assets with extra - nodes = super()._pregenerate_assets_bundles() + links = super()._pregenerate_assets_bundles() website = self.env['website'].search([], order='id', limit=1) if not website: - return nodes + return links nb_created = 0 - for node in nodes: - bundle_info = node[1] - bundle_url = bundle_info.get('src', '') or bundle_info.get('href', '') + for link in links: + bundle_url = link[0] if bundle_url.startswith('/web/assets/'): # example: "/web/assets/2152-ee56665/web.assets_frontend_lazy.min.js" _, _, _, id_unique, name = bundle_url.split('/') @@ -189,4 +188,4 @@ class IrQWeb(models.AbstractModel): if nb_created: _logger.runbot('%s bundle(s) were copied for website %s', nb_created, website.id) - return nodes + return links diff --git a/addons/website/tests/test_ir_asset.py b/addons/website/tests/test_ir_asset.py index 320ea6c5d8e..c7adb5f4b3b 100644 --- a/addons/website/tests/test_ir_asset.py +++ b/addons/website/tests/test_ir_asset.py @@ -39,12 +39,12 @@ class TestIrAsset(odoo.tests.HttpCase): 'active': False, }) - files = IrAsset.with_context(website_id=website_1.id)._get_asset_paths('test_bundle.irasset', css=True) + files = IrAsset._get_asset_paths('test_bundle.irasset', {'website_id': website_1.id}) self.assertEqual(len(files), 2, "There should be two assets in the specific website.") self.assertEqual(files[0][0], '/website/test/base0.css', "First asset should be the same as the base one.") self.assertEqual(files[1][0], '/website/test/specific1.css', "Second asset should be the specific one.") - files = IrAsset.with_context(website_id=website_2.id)._get_asset_paths('test_bundle.irasset', css=True) + files = IrAsset._get_asset_paths('test_bundle.irasset', {'website_id': website_2.id}) self.assertEqual(len(files), 3, "All three assets should be in the unmodified website.") self.assertEqual(files[0][0], '/website/test/base0.css', "First asset should be the base one.") self.assertEqual(files[1][0], '/website/test/base1.css', "Second asset should be the base one.") diff --git a/addons/website/tests/test_performance.py b/addons/website/tests/test_performance.py index 0fd0c5f7be6..6cf3ca95147 100644 --- a/addons/website/tests/test_performance.py +++ b/addons/website/tests/test_performance.py @@ -146,7 +146,7 @@ class TestWebsitePerformancePost(UtilPerf): @mute_logger('odoo.http') def test_50_perf_sql_web_assets(self): # assets route /web/assets/.. - self.env['ir.qweb']._generate_asset_nodes('web.assets_frontend_lazy', css=False, js=True) + self.env['ir.qweb']._generate_asset_links('web.assets_frontend_lazy', css=False, js=True) assets_url = self.env['ir.attachment'].search([('url', '=like', '/web/assets/%/web.assets_frontend_lazy%.js')], limit=1).url self.assertEqual(self._get_url_hot_query(assets_url), 4) self.assertEqual(self._get_url_hot_query(assets_url, cache=False), 4) diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py index fd54f1c0171..0cce294033d 100644 --- a/addons/website/tests/test_ui.py +++ b/addons/website/tests/test_ui.py @@ -222,7 +222,9 @@ class TestUi(odoo.tests.HttpCase): self.start_tour("/", 'website_navbar_menu') def test_05_specific_website_editor(self): + asset_bundle_xmlid = 'website.assets_wysiwyg' website_default = self.env['website'].search([], limit=1) + new_website = self.env['website'].create({'name': 'New Website'}) code = b"document.body.dataset.hello = 'world';" @@ -231,7 +233,7 @@ class TestUi(odoo.tests.HttpCase): 'mimetype': 'text/javascript', 'datas': base64.b64encode(code), }) - custom_url = '/web/content/%s/%s' % (attach.id, attach.name) + custom_url = '/_custom/web/content/%s/%s' % (attach.id, attach.name) attach.url = custom_url self.env['ir.asset'].create({ @@ -240,6 +242,19 @@ class TestUi(odoo.tests.HttpCase): 'path': custom_url, 'website_id': new_website.id, }) + + files, _ = self.env['ir.qweb']._get_asset_content(asset_bundle_xmlid, assets_params={'website_id': website_default.id}) + self.assertNotIn(custom_url, [f['url'] for f in files]) + base_website_bundle = self.env['ir.qweb']._get_asset_bundle(asset_bundle_xmlid, files, env=self.env, assets_params={'website_id': website_default.id}) + base_website_css_version = base_website_bundle.get_version('css') + base_website_js_version = base_website_bundle.get_version('js') + + files, _ = self.env['ir.qweb']._get_asset_content('website.assets_wysiwyg', assets_params={'website_id': new_website.id}) + self.assertIn(custom_url, [f['url'] for f in files]) + new_website_bundle_modified = self.env['ir.qweb']._get_asset_bundle(asset_bundle_xmlid, files, env=self.env, assets_params={'website_id': new_website.id}) + self.assertEqual(new_website_bundle_modified.get_version('css'), base_website_css_version) + self.assertNotEqual(new_website_bundle_modified.get_version('js'), base_website_js_version, "js version for new website should now have been changed") + url_params = url_encode({'path': '/@/'}) self.start_tour(f'/website/force/{website_default.id}?{url_params}', "generic_website_editor", login='admin') self.start_tour(f'/website/force/{new_website.id}?{url_params}', "specific_website_editor", login='admin') diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py index 6359bcaf09a..cebc1bd7637 100644 --- a/odoo/addons/base/models/assetsbundle.py +++ b/odoo/addons/base/models/assetsbundle.py @@ -57,7 +57,7 @@ class AssetsBundle(object): TRACKED_BUNDLES = ['web.assets_common', 'web.assets_backend'] - def __init__(self, name, files, env=None, css=True, js=True, debug=None): + def __init__(self, name, files, env=None, css=True, js=True, debug_assets=False, rtl=False, assets_params=None): """ :param name: bundle name :param files: files to be added to the bundle @@ -71,16 +71,16 @@ class AssetsBundle(object): self.stylesheets = [] self.css_errors = [] self.files = files - self.user_direction = self.env['res.lang']._lang_get( - self.env.context.get('lang') or self.env.user.lang - ).direction + self.rtl = rtl + self.assets_params = assets_params or {} self.has_css = css self.has_js = js self._checksum_cache = {} - self.is_debug_assets = debug and 'assets' in debug + self.is_debug_assets = debug_assets # asset-wide html "media" attribute for f in files: + extension = f['url'].rpartition('.')[2] params = { 'url': f['url'], 'filename': f['filename'], @@ -89,45 +89,37 @@ class AssetsBundle(object): } if css: css_params = { - 'media': f['media'], - 'direction': self.user_direction, + 'rtl': self.rtl, } - if f['atype'] == 'text/sass': + if extension == 'sass': self.stylesheets.append(SassStylesheetAsset(self, **params, **css_params)) - elif f['atype'] == 'text/scss': + elif extension == 'scss': self.stylesheets.append(ScssStylesheetAsset(self, **params, **css_params)) - elif f['atype'] == 'text/less': + elif extension == 'less': self.stylesheets.append(LessStylesheetAsset(self, **params, **css_params)) - elif f['atype'] == 'text/css': + elif extension == 'css': self.stylesheets.append(StylesheetAsset(self, **params, **css_params)) if js: - if f['atype'] == 'text/javascript': + if extension == 'js': self.javascripts.append(JavascriptAsset(self, **params)) - elif f['atype'] == 'text/xml': + elif extension == 'xml': self.templates.append(XMLAsset(self, **params)) - def to_node(self, css=True, js=True, async_load=False, defer_load=False, lazy_load=False): + def get_links(self): """ - :returns [(tagName, attributes, content)] if the tag is auto close + :returns a list of tuple. a tuple can be (url, None) or (None, inlineContent) """ response = [] - if css and self.stylesheets: + if self.has_css and self.stylesheets: css_attachments = self.css(is_minified=not self.is_debug_assets) or [] for attachment in css_attachments: if self.is_debug_assets: - href = self.get_debug_asset_url(extra='rtl/' if self.user_direction == 'rtl' else '', + href = self.get_debug_asset_url(extra='rtl/' if self.rtl else '', name=css_attachments.name, extension='') else: href = attachment.url - attr = dict([ - ["type", "text/css"], - ["rel", "stylesheet"], - ["href", href], - ['data-asset-bundle', self.name], - ['data-asset-version', self.get_version('css')], - ]) - response.append(("link", attr, None)) + response.append((href, None)) if self.css_errors: msg = '\n'.join(self.css_errors).replace('"', '\\"').replace('\n', '\\n') js_error = """ @@ -139,24 +131,13 @@ class AssetsBundle(object): } }); """ % msg - response.append(JavascriptAsset(self, inline=js_error).to_node()) - response.append(StylesheetAsset(self, url="/web/static/lib/bootstrap/dist/css/bootstrap.css").to_node()) + response.append((None, js_error)) + response.append(('/web/static/lib/bootstrap/dist/css/bootstrap.css', None)) - if js and self.javascripts: + if self.has_js and self.javascripts: 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 - # (defer is probably not needed there anyways) - ["defer", "defer" if defer_load else None], - ["type", "text/javascript"], - ["data-src" if lazy_load else "src", src], - ['data-asset-bundle', self.name], - ['data-asset-version', self.get_version('js')], - ['onerror', '__odooAssetError=1'] - ]) - response.append(("script", attr, None)) + response.append((src, None)) return response @@ -192,6 +173,7 @@ class AssetsBundle(object): 'name': name, 'sep': sep, 'extension': extension, + 'params': self.assets_params, } def get_asset_url(self, id='%', unique='%', extra='', name='%', sep="%", extension='%'): @@ -229,10 +211,10 @@ class AssetsBundle(object): ira = self.env['ir.attachment'] is_css = extension in ['css', 'min.css', 'css.map'] url = self.get_asset_url( - extra='%s' % ('rtl/' if is_css and self.user_direction == 'rtl' else ''), + extra='%s' % ('rtl/' if is_css and self.rtl else ''), name=self.name, - sep='', - extension='.%s' % extension + sep='.', + extension=extension, ) domain = [ @@ -244,8 +226,7 @@ class AssetsBundle(object): if attachments: self._unlink_attachments(attachments) - # force bundle invalidation on other workers - self.env['ir.qweb'].clear_caches() + # clear_cache was removed return True @@ -268,10 +249,10 @@ class AssetsBundle(object): url_pattern = self.get_asset_url( unique=unique, - extra='%s' % ('rtl/' if is_css and self.user_direction == 'rtl' else ''), # not sure about css.map + extra='%s' % ('rtl/' if is_css and self.rtl else ''), # not sure about css.map name=self.name, - sep='', - extension='.%s' % extension + sep='.', + extension=extension, ) self.env.cr.execute(""" SELECT max(id) @@ -285,6 +266,14 @@ class AssetsBundle(object): attachment_ids = [r[0] for r in self.env.cr.fetchall()] return self.env['ir.attachment'].sudo().browse(attachment_ids) + def add_post_rollback(self): + """ + In some rare cases it is possible that an attachment is created during a transaction, added to the ormcache + but the transaction is rollbacked, leading to 404 when getting the attachments. + This postrollback hook will help fix this issue by clearing the cache if it isn't commited. + """ + self.env.cr.postrollback.add(self.env.registry._Registry__cache.clear) + def save_attachment(self, extension, content): """Record the given bundle in an ir.attachment and delete all other ir.attachments referring to this bundle (with the same name and extension). @@ -318,19 +307,17 @@ class AssetsBundle(object): 'public': True, 'raw': content.encode('utf8'), } + self.add_post_rollback() attachment = ira.with_user(SUPERUSER_ID).create(values) url = self.get_asset_url( id=attachment.id, 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 - extension='' + extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.rtl else ''), + name=self.name, + sep='.', # included in fname + extension=extension, ) - values = { - 'url': url, - } - attachment.write(values) + attachment.url = url if self.env.context.get('commit_assetsbundle') is True: self.env.cr.commit() @@ -588,7 +575,7 @@ class AssetsBundle(object): sourcemap_attachment = self.get_attachments('css.map') \ or self.save_attachment('css.map', '') debug_asset_url = self.get_debug_asset_url(name=self.name, - extra='rtl/' if self.user_direction == 'rtl' else '') + extra='rtl/' if self.rtl else '') generator = SourceMapGenerator( source_root="/".join( [".." for i in range(0, len(debug_asset_url.split("/")) - 2)] @@ -634,7 +621,7 @@ class AssetsBundle(object): compiled += self.compile_css(assets[0].compile, source) # We want to run rtlcss on normal css, so merge it in compiled - if self.user_direction == 'rtl': + if self.rtl: stylesheet_assets = [asset for asset in self.stylesheets if not isinstance(asset, (SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset))] compiled += '\n'.join([asset.get_source() for asset in stylesheet_assets]) compiled = self.run_rtlcss(compiled) @@ -798,9 +785,6 @@ class WebAsset(object): except ValueError: raise AssetNotFound("Could not find %s" % self.name) - def to_node(self): - raise NotImplementedError() - @property def last_modified(self): if self._last_modified is None: @@ -882,21 +866,6 @@ class JavascriptAsset(WebAsset): except AssetError as e: return u"console.error(%s);" % json.dumps(to_text(e)) - def to_node(self): - if self.url: - return ("script", dict([ - ["type", "text/javascript"], - ["src", self.html_url], - ['data-asset-bundle', self.bundle.name], - ['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], - ]), self.with_header()) def with_header(self, content=None, minimal=True): if minimal: @@ -927,7 +896,7 @@ class XMLAsset(WebAsset): try: content = super()._fetch_content() except AssetError as e: - return f'{json.dumps(to_text(e))}' + return u"console.error(%s);" % 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() @@ -939,17 +908,6 @@ class XMLAsset(WebAsset): def bundle_version(self): return self.bundle.get_version('js') - def to_node(self): - attributes = { - 'async': 'async', - 'defer': 'defer', - 'type': 'text/xml', - 'data-src': self.html_url, - 'data-asset-bundle': self.bundle.name, - 'data-asset-version': self.bundle_version, - } - return ("script", attributes, None) - def with_header(self, content=None): if content is None: content = self.content @@ -981,31 +939,22 @@ class StylesheetAsset(WebAsset): rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U) rx_charset = re.compile(r'(@charset "[^"]+";)', re.U) - def __init__(self, *args, media=None, direction=None, **kw): - self.media = media - self.direction = direction + def __init__(self, *args, rtl=False, **kw): + self.rtl = rtl super().__init__(*args, **kw) - if self.direction == 'rtl' and self.url: + if self.rtl and self.url: self.html_url_args = self.url.rsplit('.', 1) self.html_url_format = '%%s/%s/%s.%%s' % ('rtl', self.bundle.name) self.html_url_args = tuple(self.html_url_args) - @property - def content(self): - content = super().content - if self.media: - 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 + direction = (self.rtl and 'rtl') or 'ltr' + return f'{self.url or self.inline},{self.last_modified},{direction}' def _fetch_content(self): try: @@ -1047,26 +996,6 @@ class StylesheetAsset(WebAsset): content = re.sub(r' *([{}]) *', r'\1', content) return self.with_header(content) - def to_node(self): - if self.url: - attr = dict([ - ["type", "text/css"], - ["rel", "stylesheet"], - ["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], - ]) - return ("link", attr, None) - else: - attr = dict([ - ["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], - ]) - return ("style", attr, self.with_header()) - class PreprocessedCSS(StylesheetAsset): rx_import = None @@ -1074,7 +1003,7 @@ class PreprocessedCSS(StylesheetAsset): def __init__(self, *args, **kw): super().__init__(*args, **kw) self.html_url_args = tuple(self.url.rsplit('/', 1)) - self.html_url_format = '%%s/%s%s/%%s.css' % ('rtl/' if self.direction == 'rtl' else '', self.bundle.name) + self.html_url_format = '%%s/%s%s/%%s.css' % ('rtl/' if self.rtl else '', self.bundle.name) def get_command(self): raise NotImplementedError diff --git a/odoo/addons/base/models/ir_asset.py b/odoo/addons/base/models/ir_asset.py index d74a30671a5..1c68e368661 100644 --- a/odoo/addons/base/models/ir_asset.py +++ b/odoo/addons/base/models/ir_asset.py @@ -8,8 +8,7 @@ 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 - +from odoo.tools.constants import ASSET_EXTENSIONS, EXTERNAL_ASSET _logger = getLogger(__name__) @@ -91,7 +90,18 @@ 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, css=False, js=False): + def _get_assets_params(self): + """ + This method can be overriden to add param _get_asset_paths call. + Those params will be part of the orm cache key + """ + return {} + + @tools.conditional( + 'xml' not in tools.config['dev_mode'], + tools.ormcache('bundle', 'tuple(sorted(assets_params.items()))'), + ) + def _get_asset_paths(self, bundle, assets_params): """ Fetches all asset file paths from a given list of addons matching a certain bundle. The returned list is composed of tuples containing the @@ -109,27 +119,21 @@ class IrAsset(models.Model): 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 css: boolean: whether or not to include style files - :param js: boolean: whether or not to include script files and template - files + :param assets_params: parameters needed by overrides, mainly website_id + see _get_assets_params :returns: the list of tuples (path, addon, bundle) """ installed = self._get_installed_addons_list() - addons = self._get_active_addons_list() + addons = self._get_active_addons_list(**assets_params) asset_paths = AssetPaths() - 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, []) + + self._fill_asset_paths(bundle, asset_paths, [], addons, installed, **assets_params) return asset_paths.list - def _fill_asset_paths(self, bundle, addons, installed, exts, asset_paths, seen): + def _fill_asset_paths(self, bundle, asset_paths, seen, addons, installed, **assets_params): """ Fills the given AssetPaths instance by applying the operations found in the matching bundle of the given addons manifests. @@ -150,22 +154,22 @@ class IrAsset(models.Model): # of the CURRENT bundle. bundle_start_index = len(asset_paths.list) - assets = self._get_related_assets([('bundle', '=', bundle)]).filtered('active') + assets = self._get_related_assets([('bundle', '=', bundle)], **assets_params).filtered('active') # 1. Process the first sequence of 'ir.asset' records for asset in assets.filtered(lambda a: a.sequence < DEFAULT_SEQUENCE): - self._process_path(bundle, asset.directive, asset.target, asset.path, addons, installed, exts, asset_paths, seen, bundle_start_index) + self._process_path(bundle, asset.directive, asset.target, asset.path, asset_paths, seen, addons, installed, bundle_start_index, **assets_params) # 2. Process all addons' manifests. for addon in addons: for command in odoo.modules.module.get_manifest(addon)['assets'].get(bundle, ()): directive, target, path_def = self._process_command(command) - self._process_path(bundle, directive, target, path_def, addons, installed, exts, asset_paths, seen, bundle_start_index) + self._process_path(bundle, directive, target, path_def, asset_paths, seen, addons, installed, bundle_start_index, **assets_params) # 3. Process the rest of 'ir.asset' records for asset in assets.filtered(lambda a: a.sequence >= DEFAULT_SEQUENCE): - self._process_path(bundle, asset.directive, asset.target, asset.path, addons, installed, exts, asset_paths, seen, bundle_start_index) + self._process_path(bundle, asset.directive, asset.target, asset.path, asset_paths, seen, addons, installed, bundle_start_index, **assets_params) - def _process_path(self, bundle, directive, target, path_def, addons, installed, exts, asset_paths, seen, bundle_start_index): + def _process_path(self, bundle, directive, target, path_def, asset_paths, seen, addons, installed, bundle_start_index, **assets_params): """ This sub function is meant to take a directive and a set of arguments and apply them to the current asset_paths list @@ -180,18 +184,17 @@ class IrAsset(models.Model): """ if directive == INCLUDE_DIRECTIVE: # recursively call this function for each INCLUDE_DIRECTIVE directive. - self._fill_asset_paths(path_def, addons, installed, exts, asset_paths, seen + [bundle]) + self._fill_asset_paths(path_def, asset_paths, seen + [bundle], addons, installed, **assets_params) return - if can_aggregate(path_def): - paths = self._get_paths(path_def, installed, exts) + paths = self._get_paths(path_def, installed) 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: + target_paths = self._get_paths(target, installed) + if not target_paths and target.rpartition('.')[2] not in ASSET_EXTENSIONS: # nothing to do: the extension of the target is wrong return if target_paths: @@ -237,14 +240,10 @@ class IrAsset(models.Model): :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, _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) + assets_params = self._get_assets_params() + asset_paths = self._get_asset_paths(root_bundle, assets_params) for path, _full_path, bundle, _modified in asset_paths: if path == target_path: @@ -280,7 +279,7 @@ class IrAsset(models.Model): return misc.topological_sort({manif['name']: manif['depends'] for manif in manifs}) @api.model - @tools.ormcache_context(keys='install_module') + @tools.ormcache() def _get_installed_addons_list(self): """ Returns the list of all installed addons. @@ -288,10 +287,9 @@ class IrAsset(models.Model): """ # 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.union(odoo.conf.server_wide_modules or []).union(self.env.context.get('install_module', [])) + return self.env.registry._init_modules.union(odoo.conf.server_wide_modules or []) - def _get_paths(self, path_def, installed, extensions=None): + def _get_paths(self, path_def, installed): """ 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. @@ -352,11 +350,7 @@ class IrAsset(models.Model): msg += " It may be due to security reasons." _logger.warning(msg) # Paths are filtered on the extensions (if any). - return [ - path - for path in paths - if not extensions or path[0].rsplit('.', maxsplit=1)[-1] in extensions - ] + return paths def _process_command(self, command): """Parses a given command to return its directive, target and path definition.""" diff --git a/odoo/addons/base/models/ir_qweb.py b/odoo/addons/base/models/ir_qweb.py index 3e7eae8a7dc..dfd22c05ae9 100644 --- a/odoo/addons/base/models/ir_qweb.py +++ b/odoo/addons/base/models/ir_qweb.py @@ -2154,7 +2154,7 @@ class IrQWeb(models.AbstractModel): xmlid = el.attrib.pop('t-call-assets') css = self._compile_bool(el.attrib.pop('t-css', True)) js = self._compile_bool(el.attrib.pop('t-js', True)) - async_load = self._compile_bool(el.attrib.pop('async_load', False)) + # async_load support was removed defer_load = self._compile_bool(el.attrib.pop('defer_load', False)) lazy_load = self._compile_bool(el.attrib.pop('lazy_load', False)) media = el.attrib.pop('media', False) @@ -2164,7 +2164,6 @@ class IrQWeb(models.AbstractModel): css={css}, js={js}, debug=values.get("debug"), - async_load={async_load}, defer_load={defer_load}, lazy_load={lazy_load}, media={media!r}, @@ -2412,15 +2411,28 @@ class IrQWeb(models.AbstractModel): return (attributes, content, inherit_branding) - def _get_asset_nodes(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None): + def _get_asset_nodes(self, bundle, css=True, js=True, debug=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(bundle, css, js, debug, async_load, defer_load, lazy_load, media) + media = css and media or None + links = self._get_asset_links(bundle, css=css, js=js, debug=debug) + return self._links_to_nodes(links, defer_load=defer_load, lazy_load=lazy_load, media=media) + + def _get_asset_links(self, bundle, css=True, js=True, debug=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. + """ + rtl = self.env['res.lang']._lang_get_direction(self.env.context.get('lang') or self.env.user.lang) == 'rtl' + assets_params = self.env['ir.asset']._get_assets_params() # website_id + debug_assets = debug and 'assets' in debug + + if debug_assets: + return self._generate_asset_links(bundle, css=css, js=js, debug_assets=True, assets_params=assets_params, rtl=rtl) else: - return self._generate_asset_nodes_cache(bundle, css, js, debug, async_load, defer_load, lazy_load, media) + return self._generate_asset_links_cache(bundle, css=css, js=js, assets_params=assets_params, rtl=rtl) # qweb cache feature @@ -2472,80 +2484,91 @@ class IrQWeb(models.AbstractModel): # in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear # by restarting the server after updating the source code (or using the "Clear server cache" in debug tools) 'xml' not in tools.config['dev_mode'], - tools.ormcache('bundle', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', 'media', 'tuple(self.env.context.get(k) for k in self._get_template_cache_keys())'), + tools.ormcache('bundle', 'css', 'js', 'tuple(sorted(assets_params.items()))', 'rtl'), ) - def _generate_asset_nodes_cache(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None): - return self._generate_asset_nodes(bundle, css, js, debug, async_load, defer_load, lazy_load, media) + def _generate_asset_links_cache(self, bundle, css=True, js=True, assets_params=None, rtl=False): + return self._generate_asset_links(bundle, css, js, False, assets_params, rtl) - @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) + def _get_asset_content(self, bundle, assets_params=None): + if assets_params is None: + assets_params = self.env['ir.asset']._get_assets_params() # website_id + asset_paths = self.env['ir.asset']._get_asset_paths(bundle=bundle, assets_params=assets_params) files = [] - remains = [] + external_asset = [] 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 - - mimetype = None - if is_js: - mimetype = 'text/javascript' - elif is_css: - mimetype = f'text/{ext}' - elif is_xml: - mimetype = 'text/xml' - if full_path is not EXTERNAL_ASSET: files.append({ - 'atype': mimetype, 'url': path, 'filename': full_path, 'content': '', - 'media': media, 'last_modified': last_modified, }) else: - if is_js: - tag = 'script' - attributes = { - "type": mimetype, - } - attributes["data-src" if lazy_load else "src"] = path - if defer_load: - # lazy_load will add defer in JS otherwise this is not - # W3C valid (defer is probably not needed there anyways) - attributes["defer"] = "defer" - elif is_css: - tag = 'link' - attributes = { - "type": mimetype, - "rel": "stylesheet", - "href": path, - 'media': media, - } - elif is_xml: - tag = 'script' - attributes = { - "type": mimetype, - "async": "async", - "rel": "prefetch", - "data-src": path, - } - remains.append((tag, attributes, None)) + external_asset.append((path, None)) + return (files, external_asset) - return (files, remains) + def _get_asset_bundle(self, bundle_name, files=None, env=None, css=True, js=True, debug_assets=False, rtl=False, assets_params=None): + return AssetsBundle(bundle_name, files, env=env, css=css, js=js, debug_assets=debug_assets, rtl=rtl, assets_params=assets_params) - 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 _links_to_nodes(self, paths, defer_load=False, lazy_load=False, media=None): + return [self._link_to_node(path, defer_load=defer_load, lazy_load=lazy_load, media=media) for path in paths] - 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, 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, async_load=async_load, defer_load=defer_load, lazy_load=lazy_load) + def _link_to_node(self, descriptor, defer_load=False, lazy_load=False, media=None): + path, js_content = descriptor # small hack to manage css compilation error, to transform into a full css solution or saved js error + ext = path.rsplit('.', maxsplit=1)[-1] if path else 'js' + 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: + return + + if is_js: + is_asset_bundle = path and path.startswith('/web/assets/') + attributes = { + 'type': 'text/javascript', + } + + if (defer_load or lazy_load): + attributes['defer'] = 'defer' + if path: + if lazy_load: + attributes['data-src'] = path + else: + attributes['src'] = path + + if is_asset_bundle: + attributes['onerror'] = "__odooAssetError=1" + + if js_content: + attributes['charset'] = 'utf-8' + + return ('script', attributes, js_content) + + + if is_css: + attributes = { + 'type': f'text/{ext}', # we don't really expect to have anything else than pure css here + 'rel': 'stylesheet', + 'href': path, + 'media': media, + } + return ('link', attributes, None) + + if is_xml: + attributes = { + 'type': 'text/xml', + 'async': 'async', + 'rel': 'prefetch', + 'data-src': path, + } + return ('script', attributes, None) + + def _generate_asset_links(self, bundle, css=True, js=True, debug_assets=False, assets_params=None, rtl=False): + aggregable_files, external_assets = self._get_asset_content(bundle, assets_params=assets_params) + external_assets = [node for node in external_assets if (css and node[0].rpartition('.')[2] in STYLE_EXTENSIONS) or (js and node[0].rpartition('.')[2] in SCRIPT_EXTENSIONS)] + asset_bundle = self._get_asset_bundle(bundle, aggregable_files, env=self.env, css=css, js=js, debug_assets=debug_assets, rtl=rtl, assets_params=assets_params) + bundle_links = asset_bundle.get_links() + return external_assets + bundle_links def _get_asset_link_urls(self, bundle, debug=False): asset_nodes = self._get_asset_nodes(bundle, js=False, debug=debug) @@ -2575,16 +2598,16 @@ class IrQWeb(models.AbstractModel): js_bundles.add(asset) if css: css_bundles.add(asset) - nodes = [] + links = [] start = time.time() for bundle in sorted(js_bundles): - nodes += self._generate_asset_nodes(bundle, css=False, js=True) + links += self._generate_asset_links(bundle, css=False, js=True) _logger.info('JS Assets bundles generated in %s seconds', time.time()-start) start = time.time() for bundle in sorted(css_bundles): - nodes += self._generate_asset_nodes(bundle, css=True, js=False) + links += self._generate_asset_links(bundle, css=True, js=False) _logger.info('CSS Assets bundles generated in %s seconds', time.time()-start) - return nodes + return links def render(template_name, values, load, **options): diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py index 1217dace5c6..136de75af5f 100644 --- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py +++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py @@ -122,47 +122,57 @@ class FileTouchable(AddonManifestPatched): self.touches[filepath] = touch_time or time.time() return patch('os.path.getmtime', lambda filename: self.touches.get(filename) or GETMTINE(filename)) +@tagged('-at_install', 'post_install') +class Test404(HttpCase): + def get_assets_node_cache(self): + return [e for e in self.env.registry._Registry__cache.d if e[0] == 'ir.qweb' and '_generate_' in str(e[1])] #_generate_asset_nodes_cache + + def test_rollback(self): + """ + In some rare case, attachments can be generatted, added to the cache but the transaction is rollbacked leading to 404 + this actually tests the add_post_rollback behaviour + """ + self.env['ir.attachment'].search([('url', '=like', '/web/assets/%web.assets_frontend%')]).unlink() + self.env.registry._clear_cache() + self.url_open('/some_404_routes') + self.assertFalse(self.get_assets_node_cache(), 'orm_cache should be emptied by rollback') + self.assertFalse(self.env['ir.attachment'].search([('url', '=like', '/web/assets/%web.assets_frontend%%')]), "attachments should have been rollbacked") + self.url_open('/') + self.assertTrue(self.env['ir.attachment'].search([('url', '=like', '/web/assets/%web.assets_frontend%%')]), "attachments should have been generated as expected") + self.assertTrue(self.get_assets_node_cache(), 'orm_cache should remain populated') + self.env.registry._clear_cache() class TestJavascriptAssetsBundle(FileTouchable): @classmethod def setUpClass(cls): super().setUpClass() # this is mainly to avoid tests breaking when executed after pre-generate + cls.maxDiff = 10000 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, debug=''): - env = (env or self.env) - files, _ = env['ir.qweb']._get_asset_content(bundle) - return AssetsBundle(bundle, files, env=env, debug=debug) + def _get_asset(self, bundle, rtl=False, debug_assets=False): + files, _ = self.env['ir.qweb']._get_asset_content(bundle) + return AssetsBundle(bundle, files, env=self.env, debug_assets=debug_assets, rtl=rtl) - def _any_ira_for_bundle(self, extension, lang=None): + def _any_ira_for_bundle(self, extension, rtl=False): """ Returns all ir.attachments associated to a bundle, regardless of the verion. """ - user_direction = self.env['res.lang']._lang_get(lang or self.env.user.lang).direction bundle = self.jsbundle_name if extension in ['js', 'min.js'] else self.cssbundle_name - rtl = 'rtl/' if extension in ['css', 'min.css'] and user_direction == 'rtl' else '' + rtl = 'rtl/' if rtl and extension in ['css', 'min.css'] else '' url = f'/web/assets/%-%/{rtl}{bundle}.{extension}' domain = [('url', '=like', url)] return self.env['ir.attachment'].search(domain) - def _node_to_list(self, nodes): - res = [] - for index, (tagName, t_attrs, content) in enumerate(nodes): - for name, value in t_attrs.items(): - res.append(value) - return res - def test_01_generation(self): """ Checks that a bundle creates an ir.attachment record when its `js` method is called for the first time and this ir.attachment is different depending on `is_minified` param. """ - self.bundle = self._get_asset(self.jsbundle_name, env=self.env) + self.bundle = self._get_asset(self.jsbundle_name) # there shouldn't be any minified attachment associated to this bundle self.assertEqual(len(self._any_ira_for_bundle('min.js')), 0, @@ -226,13 +236,13 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_03_date_invalidation(self): """ Checks that a bundle is invalidated when one of its assets' modification date is changed. """ - bundle0 = self._get_asset(self.jsbundle_name, debug="assets") + bundle0 = self._get_asset(self.jsbundle_name, debug_assets=True) bundle0.js() 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, debug="assets") + bundle1 = self._get_asset(self.jsbundle_name, debug_assets=True) with self._touch(path): bundle1.js() @@ -284,10 +294,9 @@ class TestJavascriptAssetsBundle(FileTouchable): and create a minified ir.attachment. """ debug_bundle = self._get_asset(self.jsbundle_name) - nodes = debug_bundle.to_node() - content = self._node_to_list(nodes) + content = debug_bundle.get_links() # there should be a minified file - self.assertEqual(content[3].count('test_assetsbundle.bundle1.min.js'), 1) + self.assertIn('test_assetsbundle.bundle1.min.js', content[0][0]) # there should be one minified assets created in normal mode self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, @@ -297,35 +306,14 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(len(self._any_ira_for_bundle('js')), 0, "there shouldn't be any non-minified assets created in normal mode") - def test_06_debug(self): - """ 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, 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, - "there should be one minified assets created in debug mode") - - # there should be one minified assets created in debug mode - self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, - "there should be one minified assets created in debug mode") - - # there shouldn't be any non-minified assets created in debug mode - self.assertEqual(len(self._any_ira_for_bundle('js')), 0, - "there shouldn't be any non-minified assets created in debug mode") - def test_07_debug_assets(self): """ 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, 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, - "there should be one non-minified assets created in debug assets mode") + debug_bundle = self._get_asset(self.jsbundle_name, debug_assets=True) + content = debug_bundle.get_links() + # there should be a minified file + self.assertIn('test_assetsbundle.bundle1.js', content[0][0], "there should be one non-minified assets created in debug assets mode") # there shouldn't be any minified assets created in debug mode self.assertEqual(len(self._any_ira_for_bundle('min.js')), 0, @@ -398,11 +386,10 @@ 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, 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) + debug_bundle = self._get_asset(self.cssbundle_name, debug_assets=True) + content = debug_bundle.get_links() + # there should be a minified file + self.assertEqual(content[0][0], '/web/assets/debug/test_assetsbundle.bundle2.css') # there should be one css asset created in debug mode self.assertEqual(len(self._any_ira_for_bundle('css')), 1, @@ -423,9 +410,8 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(ira0.store_fname, ira1.store_fname) # the ir.attachment records should be deduplicated in the bundle's content - nodes = bundle0.to_node() - content = self._node_to_list(nodes) - self.assertEqual(content[2].count('test_assetsbundle.bundle2.min.css'), 1) + content = bundle0.get_links() + self.assertIn('test_assetsbundle.bundle2.min.css', content[0][0]) # Language direction specific tests @@ -433,17 +419,17 @@ class TestJavascriptAssetsBundle(FileTouchable): """ Checks that a bundle creates an ir.attachment record when its `css` method is called for the first time for language with different direction and separate bundle is created for rtl direction. """ - self.bundle = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'})) + self.bundle = self._get_asset(self.cssbundle_name, rtl=True) # there shouldn't be any attachment associated to this bundle - self.assertEqual(len(self._any_ira_for_bundle('min.css', lang='ar_SY')), 0) + self.assertEqual(len(self._any_ira_for_bundle('min.css', rtl=True)), 0) self.assertEqual(len(self.bundle.get_attachments('min.css')), 0) # trigger the first generation and, thus, the first save in database self.bundle.css() # there should be one attachment associated to this bundle - self.assertEqual(len(self._any_ira_for_bundle('min.css', lang='ar_SY')), 1) + self.assertEqual(len(self._any_ira_for_bundle('min.css', rtl=True)), 1) self.assertEqual(len(self.bundle.get_attachments('min.css')), 1) def test_16_ltr_and_rtl_css_access(self): @@ -473,23 +459,22 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertEqual(ltr_version0, ltr_version1) self.assertEqual(ltr_date0, ltr_date1) - # 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, rtl=True) rtl_bundle0.css() - self.assertEqual(len(self._any_ira_for_bundle('min.css', lang='ar_SY')), 1) + self.assertEqual(len(self._any_ira_for_bundle('min.css', rtl=True)), 1) rtl_version0 = rtl_bundle0.get_version('css') - rtl_ira0 = self._any_ira_for_bundle('min.css', lang='ar_SY') + rtl_ira0 = self._any_ira_for_bundle('min.css', rtl=True) rtl_date0 = rtl_ira0.create_date - rtl_bundle1 = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'})) + rtl_bundle1 = self._get_asset(self.cssbundle_name, rtl=True) rtl_bundle1.css() - self.assertEqual(len(self._any_ira_for_bundle('min.css', lang='ar_SY')), 1) + self.assertEqual(len(self._any_ira_for_bundle('min.css', rtl=True)), 1) rtl_version1 = rtl_bundle1.get_version('css') - rtl_ira1 = self._any_ira_for_bundle('min.css', lang='ar_SY') + rtl_ira1 = self._any_ira_for_bundle('min.css', rtl=True) rtl_date1 = rtl_ira1.create_date self.assertEqual(rtl_version0, rtl_version1) @@ -507,14 +492,12 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_17_css_bundle_date_invalidation(self): """ 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, debug='assets') + ltr_bundle0 = self._get_asset(self.cssbundle_name, debug_assets=True) ltr_bundle0.css() 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'}), debug='assets') + rtl_bundle0 = self._get_asset(self.cssbundle_name, rtl=True, debug_assets=True) rtl_bundle0.css() rtl_last_modified0 = rtl_bundle0.get_checksum('css') rtl_version0 = rtl_bundle0.get_version('css') @@ -522,7 +505,7 @@ class TestJavascriptAssetsBundle(FileTouchable): # 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, debug='assets') + ltr_bundle1 = self._get_asset(self.cssbundle_name, debug_assets=True) with self._touch(path): ltr_bundle1.css() @@ -532,12 +515,12 @@ class TestJavascriptAssetsBundle(FileTouchable): 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'}), debug='assets') + rtl_bundle1 = self._get_asset(self.cssbundle_name, rtl=True, debug_assets=True) rtl_bundle1.css() 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') + rtl_ira1 = self._any_ira_for_bundle('min.css', rtl=True) self.assertNotEqual(rtl_last_modified0, rtl_last_modified1) self.assertNotEqual(rtl_version0, rtl_version1) @@ -560,7 +543,7 @@ class TestJavascriptAssetsBundle(FileTouchable): ltr_files0 = ltr_bundle0.files ltr_version0 = ltr_bundle0.get_version('css') - rtl_bundle0 = self._get_asset(self.cssbundle_name, env=self.env(context={'lang': 'ar_SY'})) + rtl_bundle0 = self._get_asset(self.cssbundle_name, rtl=True) rtl_bundle0.css() rtl_files0 = rtl_bundle0.files rtl_version0 = rtl_bundle0.get_version('css') @@ -585,11 +568,11 @@ class TestJavascriptAssetsBundle(FileTouchable): self.assertNotEqual(ltr_files0, ltr_files1) 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, rtl=True) rtl_bundle1.css() rtl_files1 = rtl_bundle1.files rtl_version1 = rtl_bundle1.get_version('css') - rtl_ira1 = self._any_ira_for_bundle('min.css', lang='ar_SY') + rtl_ira1 = self._any_ira_for_bundle('min.css', rtl=True) self.assertNotEqual(rtl_files0, rtl_files1) self.assertNotEqual(rtl_version0, rtl_version1) @@ -606,12 +589,11 @@ 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'}), debug='assets') - nodes = debug_bundle.to_node() - content = self._node_to_list(nodes) + debug_bundle = self._get_asset(self.cssbundle_name, rtl=True, debug_assets=True) + content = debug_bundle.get_links() # there should be an css assets bundle in /debug/rtl if user's lang direction is rtl and debug=assets - self.assertIn('/web/assets/debug/rtl/{0}.css'.format(self.cssbundle_name), content, + self.assertEqual('/web/assets/debug/rtl/{0}.css'.format(self.cssbundle_name), content[0][0], "there should be an css assets bundle in /debug/rtl if user's lang direction is rtl and debug=assets") # there should be an css assets bundle created in /rtl if user's lang direction is rtl and debug=assets @@ -626,29 +608,20 @@ class TestJavascriptAssetsBundle(FileTouchable): attachments = self.env['ir.attachment'].search([('url', '=like', '/web/assets/%-%/test_assetsbundle.bundle4.%')]) self.assertEqual(len(attachments), 2) - asset_data_css = etree.HTML(html).xpath('//*[@data-asset-bundle]')[0] - asset_data_js = etree.HTML(html).xpath('//*[@data-asset-bundle]')[1] - format_data = { "js": attachments[0].url, "css": attachments[1].url, - "asset_bundle_css": asset_data_css.attrib.get('data-asset-bundle'), - "asset_version_css": asset_data_css.attrib.get('data-asset-version'), - "asset_bundle_js": asset_data_js.attrib.get('data-asset-bundle'), - "asset_version_js": asset_data_js.attrib.get('data-asset-version'), - } - - self.assertEqual(html.strip(), (""" + self.assertEqual(str(html.strip()), (""" - + - + @@ -659,27 +632,20 @@ class TestJavascriptAssetsBundle(FileTouchable): attachments = self.env['ir.attachment'].search([('url', '=like', '%/test_assetsbundle.bundle4.js')]) self.assertEqual(len(attachments), 1) - asset_data_css = etree.HTML(html).xpath('//*[@data-asset-bundle]')[0] - asset_data_js = etree.HTML(html).xpath('//*[@data-asset-bundle]')[1] - format_data = { - "asset_bundle_css": asset_data_css.attrib.get('data-asset-bundle'), - "asset_version_css": asset_data_css.attrib.get('data-asset-version'), - "asset_bundle_js": asset_data_js.attrib.get('data-asset-bundle'), - "asset_version_js": asset_data_js.attrib.get('data-asset-version'), "css": '/web/assets/debug/test_assetsbundle.bundle4.css', "js": '/web/assets/debug/test_assetsbundle.bundle4.js', } - self.assertEqual(html.strip(), (""" + self.assertEqual(str(html.strip()), (""" - + - + @@ -777,11 +743,11 @@ class TestAssetsBundleWithIRAMock(FileTouchable): def _get_asset(self): 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') + return AssetsBundle(self.stylebundle_name, files, env=self.env, debug_assets=True) def _bundle(self, asset, should_create, should_unlink, reason=''): self.counter.clear() - asset.to_node() + asset.get_links() if should_create: self.assertEqual(self.counter['create'], 2, f'An attachment should have been created {reason}') else: @@ -1805,7 +1771,7 @@ class TestAssetsManifest(AddonManifestPatched): 'path': '/test_assetsbundle/%s' % path_to_dummy, }) - files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec') + 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) @@ -1822,7 +1788,7 @@ class TestAssetsManifest(AddonManifestPatched): 'bundle': 'test_assetsbundle.irassetsec', 'path': '%s' % path_to_dummy, }) - files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec') + 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): @@ -1871,7 +1837,7 @@ class TestAssetsManifest(AddonManifestPatched): 'bundle': 'test_assetsbundle.irassetsec', 'path': '/test_assetsbundle/data/ir_asset.xml', }) - files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec') + 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): @@ -1880,7 +1846,7 @@ class TestAssetsManifest(AddonManifestPatched): 'bundle': 'test_assetsbundle.irassetsec', 'path': '/test_assetsbundle/static/accessible.xml', }) - files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec') + files = self.env['ir.asset']._get_asset_paths('test_assetsbundle.irassetsec', {}) modified = files[0][3] base_path = __file__.replace('/tests/test_assetsbundle.py', '') @@ -1925,3 +1891,136 @@ class TestAssetsManifest(AddonManifestPatched): .my_div.subdiv{color: blue;} """ ) + +@tagged('-at_install', 'post_install') +class AssetsNodeOrmCacheUsage(TransactionCase): + + def cache_keys(self): + keys = self.env.registry._Registry__cache.d + asset_keys = [key for key in keys if key[0] == 'ir.asset' and '_get_asset_paths' in str(key[1])] # ignore topological sort entry + qweb_keys = [key for key in keys if key[0] == 'ir.qweb'] + return asset_keys, qweb_keys + + def test_assets_node_orm_cache_usage_debug(self): + self.env.registry._clear_cache() + + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 0) + self.assertEqual(len(qweb_keys), 0) + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', debug='tests') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', debug='1') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + # in debug=assets, the ormcache is not used for _generate_asset_links_cache + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', debug='assets') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + def test_assets_node_orm_cache_usage_file_type(self): + self.env.registry._clear_cache() + + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 0) + self.assertEqual(len(qweb_keys), 0) + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', js=True, css=False) + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', js=False, css=True) + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 2) + + # NOTE: this result is not really desired but this is the current behaviour. In practice, we usually only generate one of them. + # This could be enforced or avoided + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', js=True, css=True) + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 3) + + + def test_assets_node_orm_cache_usage_lang(self): + self.env.registry._clear_cache() + self.env['res.lang']._activate_lang('ar_SY') + self.env['res.lang']._activate_lang('fr_FR') + self.env['res.lang']._activate_lang('en_US') + + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 0) + self.assertEqual(len(qweb_keys), 0) + + self.env['ir.qweb'].with_context(lang='fr_FR')._get_asset_nodes('web.assets_backend') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + self.env['ir.qweb'].with_context(lang='en_US')._get_asset_nodes('web.assets_backend') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + self.env['ir.qweb'].with_context(lang='ar_SY')._get_asset_nodes('web.assets_backend') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 2) # a second cache entry is created for rtl + + def test_assets_node_orm_cache_usage_website(self): + if self.env['ir.module.module'].search([('name', '=', 'website'), ('state', '=', 'uninstalled')]): + return # only makes sence if website is installed + self.env.registry._clear_cache() + + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 0) + self.assertEqual(len(qweb_keys), 0) + + self.env['ir.qweb'].with_context(website_id=None)._get_asset_nodes('web.assets_backend') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + self.env['ir.qweb'].with_context(website_id=1)._get_asset_nodes('web.assets_backend') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 2) # the content may be different for different websites, even if it is not always the case + self.assertEqual(len(qweb_keys), 2) + + def test_assets_node_orm_cache_usage_node_flags(self): + self.env.registry._clear_cache() + + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 0) + self.assertEqual(len(qweb_keys), 0) + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1) + self.assertEqual(len(qweb_keys), 1) + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', media='print') + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1, "media shouldn't create another entry") + self.assertEqual(len(qweb_keys), 1, "media shouldn't create another entry") + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', defer_load=True) + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1, "defer_load shouldn't create another entry") + self.assertEqual(len(qweb_keys), 1, "defer_load shouldn't create another entry") + + self.env['ir.qweb']._get_asset_nodes('web.assets_backend', lazy_load=True) + asset_keys, qweb_keys = self.cache_keys() + self.assertEqual(len(asset_keys), 1, "lazy_load shouldn't create another entry") + self.assertEqual(len(qweb_keys), 1, "lazy_load shouldn't create another entry") diff --git a/odoo/tests/common.py b/odoo/tests/common.py index 2614111e1c2..2fa3849de7f 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -653,6 +653,7 @@ class BaseCase(case.TestCase, metaclass=MetaCase): when normalizing both archs. Takes either "xml" or "html" :type parser: str """ + self.maxDiff = 10000 if original: original = _normalize_arch_for_assert(original, parser) if expected: