From a2f8e18b76e52e1a349b77691d0af18edc09a1be Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Thu, 24 Aug 2023 15:40:36 +0200 Subject: [PATCH] [IMP] website, base: generate distinct templates for new pages This commit is a preparation for the new page from template feature. In order to make it possible for new page templates to be customizable at several levels from themes, it was decided to create several layers of primary templates. Those templates are build from the descriptions found in manifest files under the new `new_page_templates` key. The same principle is also applied for configurator pages (described in manifest files under the `snippet_lists` key) because we noticed that some of the changes that were made in themes for some blocks were not supposed to impact the "drag'n'drop" version of the block, but only the version used inside the pages generated by the configurator. (E.g. connecting shapes between blocks) The manifest entries now have the following structure: ```py 'snippet_lists': { 'somepagename': ['s_block_name', ...], }, 'new_template_pages': { 'somecategoryname': { 'sometemplatename': ['s_block_name', ...], }, }, ``` This commit finds those entries in the manifests and creates the following primary templates: - `s_block_name`: already exists, this is the block that is drag and dropped using the website builder - `configurator_s_block_name`: specialization of `s_block_name` used in all pages generated by the configurator - `configurator_somepagename_s_block_name`: specialization of `configurator_s_block_name` for that specific page - `new_page_template_s_block_name`: specialization of `s_block_name` used in all new page templates - `new_page_template_somecategoryname_s_block_name`: specialization of `new_page_template_s_block_name` used in new page templates of that specific category - `new_page_template_somecategoryname_sometemplatename_s_block_name`: specialization of `new_page_template_somecategoryname_s_block_name` for that specific template For the template pages defined in `website` it also creates primary templates that assemble `t-snippet-call`s of the most specific block templates. Those templates are named `new_page_template_sections_somecategoryname_sometemplatename`. task-3381714 Part-of: odoo/odoo#126719 --- addons/website/__manifest__.py | 51 ++++ addons/website/models/ir_module_module.py | 230 +++++++++++++++++- .../views/new_page_template_templates.xml | 14 ++ odoo/addons/base/tests/test_module.py | 1 + odoo/modules/module.py | 1 + 5 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 addons/website/views/new_page_template_templates.xml 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': '', + } + + def get_distinct_snippet_names(structure): + """ Returns the distinct leaves of the structure (tree leaf's list + elements). + + :param structure: dict or list or snippet names + :return: distinct snippet names + """ + items = [] + for value in structure.values(): + if isinstance(value, list): + items.extend(value) + else: + items.extend(get_distinct_snippet_names(value)) + return set(items) + + create_count = 0 + manifest = get_manifest(self.name) + + # ------------------------------------------------------------ + # Configurator + # ------------------------------------------------------------ + + configurator_snippets = manifest['snippet_lists'] + + # Generate general configurator snippet templates + create_values = [] + # Every distinct snippet name across all configurator pages. + for snippet_name in get_distinct_snippet_names(configurator_snippets): + create_values.append(get_create_vals( + f"Snippet {snippet_name!r} for pages generated by the configurator", + snippet_name, '%s', 'configurator_%s' + )) + create_count += create_missing_views(create_values) + + # Generate configurator snippet templates for specific pages + create_values = [] + for page_name in configurator_snippets: + for snippet_name in configurator_snippets[page_name]: + create_values.append(get_create_vals( + f"Snippet {snippet_name!r} for {page_name!r} pages generated by the configurator", + snippet_name, 'configurator_%s', f'configurator_{page_name}_%s' + )) + create_count += create_missing_views(create_values) + + # ------------------------------------------------------------ + # New page templates + # ------------------------------------------------------------ + + templates = manifest['new_page_templates'] + + # Generate general new page snippet templates + create_values = [] + # Every distinct snippet name across all new page templates. + for snippet_name in get_distinct_snippet_names(templates): + create_values.append(get_create_vals( + f"Snippet {snippet_name!r} for new page templates", + snippet_name, '%s', 'new_page_template_%s' + )) + create_count += create_missing_views(create_values) + + # Generate new page snippet templates for new page template groups + create_values = [] + for group in templates: + # Every distinct snippet name across all new page templates of group. + for snippet_name in get_distinct_snippet_names(templates[group]): + create_values.append(get_create_vals( + f"Snippet {snippet_name!r} for new page {group!r} templates", + snippet_name, 'new_page_template_%s', f'new_page_template_{group}_%s' + )) + create_count += create_missing_views(create_values) + + # Generate new page snippet templates for specific new page templates within groups + create_values = [] + for group in templates: + for template_name in templates[group]: + for snippet_name in templates[group][template_name]: + create_values.append(get_create_vals( + f"Snippet {snippet_name!r} for new page {group!r} template {template_name!r}", + snippet_name, f'new_page_template_{group}_%s', f'new_page_template_{group}_{template_name}_%s' + )) + create_count += create_missing_views(create_values) + + if create_count: + _logger.info("Generated %s primary snippet templates for %r", create_count, self.name) + + if self.name == 'website': + # Invoke for themes and website_* - otherwise on -u website, the + # additional primary snippets they require are deleted by _process_end. + for module in self.env['ir.module.module'].search([ + ('state', '=', 'installed'), + '|', + ('name', '=like', f'{escape_psql("theme_")}%'), + ('name', '=like', f'{escape_psql("website_")}%'), + ]): + module._generate_primary_snippet_templates() + + def _generate_primary_page_templates(self): + """ Generates page templates based on manifest entries. """ + View = self.env['ir.ui.view'] + manifest = get_manifest(self.name) + templates = manifest['new_page_templates'] + + # TODO Find a way to create theme and other module's template patches + # Create or update template views per group x key + create_values = [] + for group in templates: + for template_name in templates[group]: + xmlid = f'{self.name}.new_page_template_sections_{group}_{template_name}' + wrapper = f'%s.new_page_template_{group}_{template_name}_%s' + calls = '\n '.join([ + f'''''' + for snippet_key in templates[group][template_name] + ]) + create_values.append({ + 'name': f"New page template: {template_name!r} in {group!r}", + 'type': 'qweb', + 'key': xmlid, + 'arch': f'
\n {calls}\n
', + }) + keys = [values['key'] for values in create_values] + existing_primary_templates = View.search_read([('mode', '=', 'primary'), ('key', 'in', keys)], ['key']) + existing_primary_template_keys = {data['key']: data['id'] for data in existing_primary_templates} + missing_create_values = [] + update_count = 0 + for create_value in create_values: + if create_value['key'] in existing_primary_template_keys: + View.browse(existing_primary_template_keys[create_value['key']]).with_context(no_cow=True).write({ + 'arch': create_value['arch'], + }) + update_count += 1 + else: + missing_create_values.append(create_value) + if missing_create_values: + missing_records = View.create(missing_create_values) + self._create_model_data(missing_records) + _logger.info('Generated %s primary page templates for %r', len(missing_create_values), self.name) + # Prevent deletion by _process_end + for values in create_values: + self.env['ir.model.data']._load_xmlid(values['key']) + if update_count: + _logger.info('Updated %s primary page templates for %r', update_count, self.name) diff --git a/addons/website/views/new_page_template_templates.xml b/addons/website/views/new_page_template_templates.xml new file mode 100644 index 00000000000..83017b75f5f --- /dev/null +++ b/addons/website/views/new_page_template_templates.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/odoo/addons/base/tests/test_module.py b/odoo/addons/base/tests/test_module.py index 512d9888ffd..9833d9a58c3 100644 --- a/odoo/addons/base/tests/test_module.py +++ b/odoo/addons/base/tests/test_module.py @@ -56,6 +56,7 @@ class TestModuleManifest(BaseCase): 'license': 'MIT', 'live_test_url': '', 'name': f'Temp {self.module_name}', + 'new_page_templates': {}, 'post_init_hook': '', 'post_load': None, 'pre_init_hook': '', diff --git a/odoo/modules/module.py b/odoo/modules/module.py index 659a93e5509..f18f25dd5b0 100644 --- a/odoo/modules/module.py +++ b/odoo/modules/module.py @@ -46,6 +46,7 @@ _DEFAULT_MANIFEST = { 'images_preview_theme': {}, # website themes #license, mandatory 'live_test_url': '', # website themes + 'new_page_templates': {}, # website themes #name, mandatory 'post_init_hook': '', 'post_load': None,