From fa1c05e7bb2959c6025a806a4348f69ed225b4ec Mon Sep 17 00:00:00 2001 From: Younn Olivier Date: Wed, 30 Jun 2021 09:42:12 +0000 Subject: [PATCH] [IMP] website: disable assets for unused snippets Based on their path, we retrieve snippets assets, assuming that it will be in the form : /snippets/{snippet_id}/{version}.{scss|js} Then we check the template definition uses that version, or if any views or html fields contains a snippet with such version exist. If not, the asset is deactivated. For that, we are looking for the data-snippet attribute in snippets already dropped. For the template definition, we look at the first class definition if a data version attribute is also defined. task-2212216 closes odoo/odoo#73005 Signed-off-by: Romain Derie --- addons/website/models/website.py | 95 +++++++++++++ addons/website/tests/__init__.py | 1 + .../test_disable_unused_snippets_assets.py | 132 ++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 addons/website/tests/test_disable_unused_snippets_assets.py diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 4dfd6d9bc1f..782f3a12859 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -1342,3 +1342,98 @@ class Website(models.Model): def _get_cached(self, field): return self._get_cached_values()[field] + + def _get_html_fields(self): + html_fields = [('ir_ui_view', 'arch_db')] + cr = self.env.cr + cr.execute(r""" + SELECT f.model, + f.name + FROM ir_model_fields f + JOIN ir_model m + ON m.id = f.model_id + WHERE f.ttype = 'html' + AND f.store = true + AND m.transient = false + AND f.model NOT LIKE 'ir.actions%' + AND f.model != 'mail.message' + """) + for model, name in cr.fetchall(): + table = self.env[model]._table + if tools.table_exists(cr, table) and tools.column_exists(cr, table, name): + html_fields.append((table, name)) + return html_fields + + def _get_snippets_assets(self): + """Returns every parent snippet asset from the database, filtering out + their potential overrides defined in other modules. As they share the same + snippet_id, asset_version and asset_type, it is possible to do that using + Postgres' DISTINCT ON and ordering by asset_id, as overriden assets will be + created later than their parents. + The assets are returned in the form of a list of tuples : + [(snippet_module, snippet_id, asset_version, asset_type, asset_id)] + """ + self.env.cr.execute(r""" + SELECT DISTINCT ON (snippet_id, asset_version, asset_type) + regexp_matches[1] AS snippet_module, + regexp_matches[2] AS snippet_id, + regexp_matches[3] AS asset_version, + CASE + WHEN regexp_matches[4]='scss' THEN 'css' + ELSE regexp_matches[4] + END AS asset_type, + id AS asset_id + FROM ( + SELECT REGEXP_MATCHES(PATH, '(\w*)\/.*\/snippets\/(\w*)\/(\d{3})\.(js|scss)'), + id + FROM ir_asset + ) AS regexp + ORDER BY snippet_id, asset_version, asset_type, asset_id; + """) + return self.env.cr.fetchall() + + def _is_snippet_used(self, snippet_module, snippet_id, asset_version, asset_type, html_fields): + snippet_occurences = [] + # Check snippet template definition to avoid disabling its related assets. + # This special case is needed because snippet template definitions do not + # have a `data-snippet` attribute (which is added during drag&drop). + snippet_template = self.env.ref(f'{snippet_module}.{snippet_id}', raise_if_not_found=False) + if snippet_template: + snippet_template_html = snippet_template._render() + match = re.search('<([^>]*class="[^>]*)>', snippet_template_html) + snippet_occurences.append(match.group()) + + # As well as every snippet dropped in html fields + snippet_regex = f'<([^>]*data-snippet="{snippet_id}"[^>]*)>' + snippet_dropped = 'UNION '.join(f'SELECT REGEXP_MATCHES({column}, \'{snippet_regex}\') FROM {table} ' for table, column in html_fields) + self.env.cr.execute(snippet_dropped) + results = self.env.cr.fetchall() + for r in results: + snippet_occurences.append(r[0][0]) + + for snippet in snippet_occurences: + if asset_version == '000': + if f'data-v{asset_type}' not in snippet: + return True + else: + if f'data-v{asset_type}="{asset_version}"' in snippet: + return True + return False + + @api.autovacuum + def _disable_unused_snippets_assets(self): + snippets_assets = self._get_snippets_assets() + html_fields = self._get_html_fields() + + for snippet_module, snippet_id, asset_version, asset_type, _ in snippets_assets: + is_snippet_used = self._is_snippet_used(snippet_module, snippet_id, asset_version, asset_type, html_fields) + filename_type = 'scss' if asset_type == 'css' else asset_type + assets_path = f'{snippet_id}/{asset_version}.{filename_type}' + + # The query will also set to active or inactive assets overrides, as they + # share the same snippet_id, asset_version and filename_type as their parents + self.env.cr.execute(""" + UPDATE ir_asset + SET active = %(active)s + WHERE path ~ %(assets_path)s + """, {"active": is_snippet_used, "assets_path": assets_path}) diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py index 77977a2bcc8..5286b1a70a4 100644 --- a/addons/website/tests/__init__.py +++ b/addons/website/tests/__init__.py @@ -5,6 +5,7 @@ from . import test_base_url from . import test_controllers from . import test_converter from . import test_crawl +from . import test_disable_unused_snippets_assets from . import test_get_current_website from . import test_lang_url from . import test_menu diff --git a/addons/website/tests/test_disable_unused_snippets_assets.py b/addons/website/tests/test_disable_unused_snippets_assets.py new file mode 100644 index 00000000000..1e968231700 --- /dev/null +++ b/addons/website/tests/test_disable_unused_snippets_assets.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestDisableSnippetsAssets(TransactionCase): + def setUp(self): + super().setUp() + self.View = self.env['ir.ui.view'] + self.WebsiteMenu = self.env['website.menu'] + self.Website = self.env['website'] + self.IrAsset = self.env['ir.asset'] + + self.homepage = self.View.create({ + 'name': 'Home', + 'type': 'qweb', + 'arch_db': HOMEPAGE_WITH_OUTDATED_S_WEBSITE_FORM, + 'key': 'website.homepage', + }) + self.mega_menu = self.WebsiteMenu.create({ + 'name': 'Image Gallery V001', + 'mega_menu_content': MEGA_MENU_CONTENT_IMAGE_GALLERY_V001, + }) + + self.initial_active_snippets_assets = self._get_active_snippets_assets() + + def test_homepage_with_outdated_s_website_form(self): + self.Website._disable_unused_snippets_assets() + s_website_form_000 = self._get_snippet_asset('s_website_form', '000', 'scss') + s_website_form_001 = self._get_snippet_asset('s_website_form', '001', 'scss') + s_image_gallery_000 = self._get_snippet_asset('s_image_gallery', '000', 'scss') + s_image_gallery_001 = self._get_snippet_asset('s_image_gallery', '001', 'scss') + self.assertEqual(s_website_form_000.active, True) + self.assertEqual(s_website_form_001.active, True) + self.assertEqual(s_image_gallery_000.active, False) + self.assertEqual(s_image_gallery_001.active, True) + + unwanted_snippets_assets_changes = set(self.initial_active_snippets_assets) - set(self._get_active_snippets_assets()) - set([s_image_gallery_000.path]) + + # The vaccuum should not have activated/deactivated any other snippet asset than the original ones + self.assertEqual( + len(unwanted_snippets_assets_changes), + 0, + 'Following snippets are not following the snippet versioning system structure, or their previous assets have not been deactivated:\n' + + '\n'.join(unwanted_snippets_assets_changes)) + + def test_homepage_with_s_website_form_V001(self): + self.homepage.write({ + 'arch_db': HOMEPAGE_WITH_S_WEBSITE_FORM_V001, + }) + self.mega_menu.write({ + 'mega_menu_content': MEGA_MENU_CONTENT_IMAGE_GALLERY_OUTDATED, + }) + self.Website._disable_unused_snippets_assets() + s_website_form_000 = self._get_snippet_asset('s_website_form', '000', 'scss') + s_website_form_001 = self._get_snippet_asset('s_website_form', '001', 'scss') + s_image_gallery_000 = self._get_snippet_asset('s_image_gallery', '000', 'scss') + s_image_gallery_001 = self._get_snippet_asset('s_image_gallery', '001', 'scss') + self.assertEqual(s_website_form_000.active, False) + self.assertEqual(s_website_form_001.active, True) + self.assertEqual(s_image_gallery_000.active, True) + self.assertEqual(s_image_gallery_001.active, True) + + def _get_snippet_asset(self, snippet_id, asset_version, asset_type): + return self.IrAsset.search([('path', '=', 'website/static/src/snippets/' + snippet_id + '/' + asset_version + '.' + asset_type)], limit=1) + + def _get_active_snippets_assets(self): + return self.IrAsset.search([('path', 'like', 'snippets'), ('active', '=', True)]).mapped('path') + +HOMEPAGE_WITH_S_WEBSITE_FORM_V001 = """ + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+""" + +HOMEPAGE_WITH_OUTDATED_S_WEBSITE_FORM = """ + + + +
+
+
+
+
+
+
+
+
+
+
+
+""" + +MEGA_MENU_CONTENT_IMAGE_GALLERY_V001 = """ +
+
+
+
+ + +""" + +MEGA_MENU_CONTENT_IMAGE_GALLERY_OUTDATED = """ +
+
+
+
+ + +"""