diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py
index ac712358924..2f9554c40cf 100644
--- a/addons/website/__manifest__.py
+++ b/addons/website/__manifest__.py
@@ -95,6 +95,7 @@
'views/snippets/s_embed_code.xml',
'views/snippets/s_website_form.xml',
'views/snippets/s_searchbar.xml',
+ 'views/new_page_template_templates.xml',
'views/website_views.xml',
'views/website_pages_views.xml',
'views/website_visitor_views.xml',
@@ -306,5 +307,55 @@
('remove', 'website/static/src/components/dialog/*.dark.scss'),
],
},
+ 'new_page_templates': {
+ 'about': {
+ '0': ['s_banner', 's_text_block', 's_three_columns', 's_company_team', 's_quotes_carousel'],
+ '1': ['s_picture', 's_showcase', 's_three_columns', 's_company_team', 's_quotes_carousel'],
+ '2': ['s_banner', 's_three_columns', 's_showcase', 's_company_team', 's_quotes_carousel'],
+ '3': ['s_picture', 's_three_columns', 's_company_team', 's_quotes_carousel', 's_showcase'],
+ '4': ['s_banner', 's_text_block', 's_three_columns', 's_quotes_carousel', 's_showcase'],
+ '5': ['s_picture', 's_three_columns', 's_showcase', 's_quotes_carousel', 's_company_team'],
+ },
+ 'landing': {
+ '0': ['s_cover', 's_features', 's_call_to_action', 's_media_list'],
+ '1': ['s_parallax', 's_showcase', 's_quotes_carousel', 's_call_to_action'],
+ '2': ['s_cover', 's_parallax', 's_media_list', 's_quotes_carousel'],
+ '3': ['s_cover', 's_features', 's_media_list', 's_quotes_carousel', 's_showcase'],
+ '4': ['s_carousel', 's_quotes_carousel', 's_call_to_action', 's_media_list'],
+ '5': ['s_cover', 's_features', 's_media_list', 's_parallax', 's_quotes_carousel'],
+ },
+ 'gallery': {
+ '0': ['s_image_gallery', 's_carousel', 's_quotes_carousel', 's_images_wall'],
+ '1': ['s_masonry_block', 's_carousel', 's_quotes_carousel', 's_images_wall'],
+ '2': ['s_image_gallery', 's_masonry_block', 's_carousel', 's_quotes_carousel'],
+ '3': ['s_carousel', 's_images_wall', 's_quotes_carousel', 's_masonry_block'],
+ '4': ['s_images_wall', 's_image_text', 's_text_image', 's_image_gallery'],
+ '5': ['s_image_gallery', 's_masonry_block', 's_images_wall', 's_carousel'],
+ },
+ 'services': {
+ '0': ['s_banner', 's_text_image', 's_features', 's_showcase', 's_three_columns'],
+ '1': ['s_text_image', 's_three_columns', 's_showcase', 's_features', 's_media_list'],
+ '2': ['s_banner', 's_text_image', 's_features', 's_three_columns', 's_quotes_carousel'],
+ '3': ['s_banner', 's_three_columns', 's_features', 's_product_catalog', 's_showcase'],
+ '4': ['s_text_image', 's_showcase', 's_tabs', 's_features', 's_three_columns'],
+ '5': ['s_banner', 's_three_columns', 's_showcase', 's_product_catalog', 's_features'],
+ },
+ 'pricing': {
+ '0': ['s_title', 's_numbers', 's_color_blocks_2', 's_features', 's_comparisons', 's_showcase'],
+ '1': ['s_title', 's_numbers', 's_color_blocks_2', 's_product_catalog', 's_features', 's_showcase'],
+ '2': ['s_title', 's_numbers', 's_color_blocks_2', 's_features', 's_comparisons', 's_media_list'],
+ '3': ['s_title', 's_numbers', 's_comparisons', 's_features', 's_product_catalog', 's_showcase'],
+ '4': ['s_title', 's_numbers', 's_product_catalog', 's_features', 's_showcase', 's_media_list'],
+ '5': ['s_title', 's_numbers', 's_comparisons', 's_product_catalog', 's_features', 's_showcase'],
+ },
+ 'team': {
+ '0': ['s_banner', 's_three_columns', 's_company_team', 's_quotes_carousel', 's_showcase', 's_media_list'],
+ '1': ['s_carousel', 's_media_list', 's_three_columns', 's_company_team', 's_showcase', 's_quotes_carousel'],
+ '2': ['s_banner', 's_three_columns', 's_company_team', 's_carousel', 's_media_list', 's_quotes_carousel'],
+ '3': ['s_three_columns', 's_company_team', 's_quotes_carousel', 's_showcase', 's_carousel', 's_media_list'],
+ '4': ['s_banner', 's_text_block', 's_three_columns', 's_company_team', 's_media_list', 's_showcase'],
+ '5': ['s_picture', 's_three_columns', 's_company_team', 's_quotes_carousel', 's_showcase', 's_media_list'],
+ },
+ },
'license': 'LGPL-3',
}
diff --git a/addons/website/models/ir_module_module.py b/addons/website/models/ir_module_module.py
index 2ac7b3935ce..a4c2a89b542 100644
--- a/addons/website/models/ir_module_module.py
+++ b/addons/website/models/ir_module_module.py
@@ -9,7 +9,8 @@ from odoo import api, fields, models
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
from odoo.exceptions import MissingError
from odoo.http import request
-from odoo.tools import split_every
+from odoo.modules.module import get_manifest
+from odoo.tools import escape_psql, split_every
_logger = logging.getLogger(__name__)
@@ -549,3 +550,230 @@ class IrModuleModule(models.Model):
""", (default_menu.id,))
return res
+
+ # ----------------------------------------------------------------
+ # New page templates
+ # ----------------------------------------------------------------
+
+ @api.model
+ def _create_model_data(self, views):
+ """ Creates model data records for newly created view records.
+
+ :param views: views for which model data must be created
+ """
+ self.env['ir.model.data'].create([{
+ 'name': view.key.split('.')[1],
+ 'module': view.key.split('.')[0],
+ 'model': 'ir.ui.view',
+ 'res_id': view.id,
+ } for view in views])
+
+ def _generate_primary_snippet_templates(self):
+ """ Generates snippet templates hierarchy based on manifest entries for
+ use in the configurator and when creating new pages from templates.
+ """
+ def split_key(snippet_key):
+ """ Snippets xmlid can be written without the module part, meaning
+ it is a shortcut for a website module snippet.
+
+ :param snippet_key: xmlid with or without the module part
+ 'website' is assumed to be the default module
+ :return: module and key extracted from the snippet_key
+ """
+ return snippet_key.split('.') if '.' in snippet_key else ('website', snippet_key)
+
+ def create_missing_views(create_values):
+ """ Creates the snippet primary view records that do not exist yet.
+
+ :param create_values: values of records to create
+ :return: number of created records
+ """
+ # Defensive code (low effort): `if values` should always be set
+ create_values = [values for values in create_values if values]
+
+ keys = [values['key'] for values in create_values]
+ existing_primary_template_keys = self.env['ir.ui.view'].search_fetch([
+ ('mode', '=', 'primary'), ('key', 'in', keys),
+ ], ['key']).mapped('key')
+ missing_create_values = [values for values in create_values if values['key'] not in existing_primary_template_keys]
+ missing_records = self.env['ir.ui.view'].with_context(no_cow=True).create(missing_create_values)
+ self._create_model_data(missing_records)
+
+ # Prevent deletion by _process_end
+ for values in create_values:
+ self.env['ir.model.data']._load_xmlid(values["key"])
+
+ return len(missing_records)
+
+ def get_create_vals(name, snippet_key, parent_wrap, new_wrap):
+ """ Returns the create values for the new primary template of the
+ snippet having snippet_key as its base key, having a new key
+ formatted with new_wrap, and extending a parent with the key
+ formatted with parent_wrap.
+
+ :param name: name
+ :param snippet_key: xmlid of the base block
+ :param parent_wrap: string pattern used to format the
+ snippet_key's second part to reach the parent key
+ :param new_wrap: string pattern used to format the
+ snippet_key's second part to reach the new key
+ :return: create values for the new record
+ """
+ module, xmlid = split_key(snippet_key)
+ parent_key = f'{module}.{parent_wrap % xmlid}'
+ # Equivalent to using an already cached ref, without failing on
+ # missing key - because the parent records have just been created.
+ parent_id = self.env['ir.model.data']._xmlid_to_res_model_res_id(parent_key, False)
+ if not parent_id:
+ _logger.warning("No such snippet template: %r", parent_key)
+ return None
+ return {
+ 'name': name,
+ 'key': f'{module}.{new_wrap % xmlid}',
+ 'inherit_id': parent_id[1],
+ 'mode': 'primary',
+ 'type': 'qweb',
+ 'arch': '