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 = """
+
+
+
+"""