|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +# Part of Odoo. See LICENSE file for full copyright and licensing details. |
| 3 | + |
| 4 | +from lxml import html |
| 5 | + |
| 6 | +import logging |
| 7 | +import re |
| 8 | + |
| 9 | +from odoo.addons.website.tools import MockRequest |
| 10 | +from odoo.tests import tagged, TransactionCase |
| 11 | +from odoo.tools import escape_psql |
| 12 | + |
| 13 | +_logger = logging.getLogger(__name__) |
| 14 | + |
| 15 | +CONFLICTUAL_CLASSES = [ |
| 16 | + ['btn-outline-primary', 'btn-primary', 'btn-secondary'], |
| 17 | + ['btn-block', 'btn-outline-primary'], |
| 18 | + ['container', 'container-fluid', 'o_container_small'], |
| 19 | + ['d-block', 'd-flex', 'd-inline-block', 'd-none'], |
| 20 | + ['d-block', 'd-lg-block', 'd-md-block'], |
| 21 | + ['d-flex', 'd-md-flex'], |
| 22 | + ['flex-column', 'flex-column-reverse', 'flex-row', 'flex-row-reverse'], |
| 23 | + ['g-0', 'g-col-lg-2', 'g-col-lg-3', 'g-col-lg-4', 'g-col-lg-5', 'g-col-lg-6'], |
| 24 | + ['g-0', 'g-height-5', 'g-height-8', 'g-height-10'], |
| 25 | + ['h-100', 'o_half_screen_height', 'o_full_screen_height'], |
| 26 | + ['justify-content-center', 'justify-content-start'], |
| 27 | + ['nav-link', 'nav-pills', 'nav-tabs'], |
| 28 | + ['o_cc1', 'o_cc2', 'o_cc3', 'o_cc4', 'o_cc5'], |
| 29 | + ['o_spc-medium', 'o_spc-none', 'o_spc-small'], |
| 30 | + ['oi-arrows-h', 'oi-arrows-v', 'oi-chevron-left', 'oi-chevron-right', 'oi-search'], |
| 31 | + ['position-absolute', 'position-relative'], |
| 32 | + ['s_carousel_bordered', 's_carousel_default', 's_carousel_rounded'], |
| 33 | + ['s_image_gallery_indicators_arrows_boxed', 's_image_gallery_indicators_arrows_rounded'], |
| 34 | + ['text-center', 'text-end', 'text-start'], |
| 35 | +] |
| 36 | + |
| 37 | +# For each RE, associates a whitelist |
| 38 | +CONFLICTUAL_CLASSES_RE = { |
| 39 | + # Align |
| 40 | + re.compile(r'^align-(?!(self|items)-).+'): [], |
| 41 | + re.compile(r'^align-self-.+'): [], |
| 42 | + re.compile(r'^align-items-.+'): [], |
| 43 | + # BG |
| 44 | + re.compile(r'^bg(-|_)'): [ |
| 45 | + 'bg_option_menu_gradient', |
| 46 | + ], |
| 47 | + # Col |
| 48 | + re.compile(r'^col-\d+$'): [], |
| 49 | + re.compile(r'^col-lg-.+'): [], |
| 50 | + re.compile(r'^offset-\d+$'): [], |
| 51 | + re.compile(r'^offset-lg-.+'): [], |
| 52 | + # Display |
| 53 | + re.compile(r'^display-\d$'): [], |
| 54 | + re.compile(r'^display-\d-fs$'): [], |
| 55 | + # Margin, padding |
| 56 | + re.compile(r'^m-(\d|auto)$'): [], |
| 57 | + re.compile(r'^m(x|s)-\d$'): [], |
| 58 | + re.compile(r'^m(x|e)-\d$'): [], |
| 59 | + re.compile(r'^m(y|t)-\d$'): [], |
| 60 | + re.compile(r'^m(y|b)-\d$'): [], |
| 61 | + re.compile(r'^(p(x|s)?-?\d+|padding-.+)$'): [], |
| 62 | + re.compile(r'^(p(x|e)?-?\d+|padding-.+)$'): [], |
| 63 | + re.compile(r'^(p(y|t)?-?\d+|padding-.+)$'): [], |
| 64 | + # p0+pb32 appears in Bewise and Graphene |
| 65 | + re.compile(r'^(p(y|b)?-?\d+|padding-.+)$'): ['p0'], |
| 66 | + # Font awesome |
| 67 | + re.compile(r'^fa-\dx$'): [], |
| 68 | + re.compile(r'^fa-...+'): [], |
| 69 | + # Rounded |
| 70 | + re.compile(r'^rounded-.+'): [], |
| 71 | + # Shadow |
| 72 | + re.compile(r'^shadow-.+'): [], |
| 73 | + # Shapes |
| 74 | + re.compile(r'^o_web_editor_[A-Z].+'): [], |
| 75 | + # Snippets |
| 76 | + re.compile(r'^s_.*'): [ |
| 77 | + 's_alert_md', |
| 78 | + 's_blockquote_classic', |
| 79 | + 's_carousel_bordered', 's_carousel_default', 's_carousel_rounded', |
| 80 | + 's_dynamic', 's_dynamic_empty', 's_dynamic_snippet_blog_posts', |
| 81 | + 's_blog_posts_effect_marley', 's_blog_post_big_picture', |
| 82 | + 's_col_no_bgcolor', 's_col_no_resize', |
| 83 | + 's_event_upcoming_snippet', |
| 84 | + 's_image_gallery_cover', 's_image_gallery_indicators_arrows_boxed', 's_image_gallery_indicators_arrows_rounded', |
| 85 | + 's_image_gallery_indicators_dots', 's_image_gallery_indicators_rounded', 's_image_gallery_show_indicators', |
| 86 | + 's_newsletter_list', 's_newsletter_subscribe_form', |
| 87 | + 's_parallax_is_fixed', 's_parallax_no_overflow_hidden', |
| 88 | + 's_process_steps_connector_line', |
| 89 | + 's_product_catalog_dish_name', 's_product_catalog_dish_dot_leaders', |
| 90 | + 's_table_of_content_vertical_navbar', 's_table_of_content_navbar_sticky', 's_table_of_content_navbar_wrap', |
| 91 | + 's_timeline_card', |
| 92 | + 's_website_form_custom', 's_website_form_dnone', 's_website_form_field', 's_website_form_input', 's_website_form_mark', |
| 93 | + ], |
| 94 | + # Text |
| 95 | + re.compile(r'^text-(?!(center|end|start|bg-|lg-)).*$'): [ |
| 96 | + 'text-break', |
| 97 | + ], |
| 98 | + re.compile(r'^text-bg-.*$'): [], |
| 99 | + re.compile(r'^text-lg-.*$'): [], |
| 100 | + # Width |
| 101 | + re.compile(r'^w-\d*$'): [], |
| 102 | +} |
| 103 | + |
| 104 | + |
| 105 | +@tagged('post_install', '-at_install') |
| 106 | +class TestNewPageTemplates(TransactionCase): |
| 107 | + |
| 108 | + def test_template_names(self): |
| 109 | + websites_themes = self.env['website'].get_test_themes_websites() |
| 110 | + for website in websites_themes: |
| 111 | + views = self.env['ir.ui.view'].search([ |
| 112 | + ('key', 'like', f'{website.theme_id.name}.new_page_template%_s_'), |
| 113 | + ]) |
| 114 | + if website.theme_id.name != 'theme_default': |
| 115 | + self.assertGreater(len(views), 10, "Test should have encountered some views in theme %r" % website.name) |
| 116 | + for view in views: |
| 117 | + self.assertEqual(view.mode, 'extension', "Theme's new page template customization %r should never be primary" % view.key) |
| 118 | + name = view.key.split('.')[1] |
| 119 | + parent_name = view.inherit_id.key.split('.')[1] |
| 120 | + self.assertEqual(name, parent_name, "Theme's new page template customization %r should use the same name as their parent %r" % (view.key, view.inherit_id.key)) |
| 121 | + |
| 122 | + def test_render_templates(self): |
| 123 | + errors = [] |
| 124 | + view_ids = set() |
| 125 | + websites_themes = self.env['website'].get_test_themes_websites() |
| 126 | + for website in websites_themes: |
| 127 | + with MockRequest(self.env, website=website): |
| 128 | + views = self.env['ir.ui.view'].search([ |
| 129 | + '|', '|', |
| 130 | + ('key', 'like', f'{website.theme_id.name}.s_'), |
| 131 | + ('key', 'like', f'{website.theme_id.name}.configurator'), |
| 132 | + ('key', 'like', f'{website.theme_id.name}.new_page'), |
| 133 | + ]) |
| 134 | + view_ids.update(views.ids) |
| 135 | + for view in views: |
| 136 | + try: |
| 137 | + self.env['ir.qweb']._render(view.id) |
| 138 | + except Exception: |
| 139 | + errors.append("View %s cannot be rendered" % view.key) |
| 140 | + _logger.info("Tested %s views", len(view_ids)) |
| 141 | + self.assertGreater(len(view_ids), 1250, "Test should have encountered a lot of views") |
| 142 | + self.assertFalse(errors, "No error should have been collected") |
| 143 | + |
| 144 | + def test_render_applied_templates(self): |
| 145 | + View = self.env['ir.ui.view'] |
| 146 | + errors = [] |
| 147 | + classes_inventory = set() |
| 148 | + view_count = 0 |
| 149 | + |
| 150 | + def check(theme_name, website): |
| 151 | + with MockRequest(self.env, website=website): |
| 152 | + views = View.search([ |
| 153 | + '|', '|', |
| 154 | + ('key', 'in', [ |
| 155 | + 'website.snippets', |
| 156 | + 'website.new_page_template_groups', |
| 157 | + ]), |
| 158 | + ('key', 'like', escape_psql('website.configurator_')), |
| 159 | + ('key', 'like', escape_psql('website.new_page_template_sections_')), |
| 160 | + ]) |
| 161 | + for view in views: |
| 162 | + try: |
| 163 | + # TODO: Improve the perfs of the next line |
| 164 | + # Doesn't seem to be a way to avoid one RECURSIVE |
| 165 | + # SQL Query from `_get_inheriting_views` per view |
| 166 | + html_text = self.env['ir.qweb']._render(view.id) |
| 167 | + if not html_text: |
| 168 | + continue |
| 169 | + html_tree = html.fromstring(html_text) |
| 170 | + blocks_el = html_tree.xpath("//*[@id='o_scroll']") |
| 171 | + if blocks_el: |
| 172 | + # Only look at blocks in website.snippets |
| 173 | + html_tree = blocks_el[0] |
| 174 | + for el in html_tree.xpath('//*[@class]'): |
| 175 | + classes = el.attrib['class'].split(' ') |
| 176 | + classes_inventory.update(classes) |
| 177 | + if len(classes) != len(set(classes)): |
| 178 | + errors.append("Using %r, view %r contains duplicate classes: %r" % (theme_name, view.key, classes)) |
| 179 | + for conflicting_classes in CONFLICTUAL_CLASSES: |
| 180 | + conflict = set(classes).intersection(conflicting_classes) |
| 181 | + if len(conflict) > 1: |
| 182 | + errors.append("Using %r, view %r contains conflicting classes: %r in %r" % (theme_name, view.key, conflict, classes)) |
| 183 | + for conflicting_classes_re in CONFLICTUAL_CLASSES_RE: |
| 184 | + conflict = {cl for cl in filter(conflicting_classes_re.findall, set(classes))} |
| 185 | + white_list = CONFLICTUAL_CLASSES_RE[conflicting_classes_re] |
| 186 | + conflict.difference_update(white_list) |
| 187 | + if len(conflict) > 1: |
| 188 | + errors.append("Using %r, view %r contains conflicting classes: %r in %r (according to pattern %r)" % (theme_name, view.key, conflict, classes, conflicting_classes_re.pattern)) |
| 189 | + except Exception: |
| 190 | + _logger.error("Using %r, view %r cannot be rendered", theme_name, view.key) |
| 191 | + errors.append("Using %r, view %r cannot be rendered" % (theme_name, view.key)) |
| 192 | + return len(views) |
| 193 | + |
| 194 | + view_count += check('no theme', self.env.ref('website.default_website')) |
| 195 | + websites_themes = self.env['website'].get_test_themes_websites() |
| 196 | + for website in websites_themes: |
| 197 | + view_count += check(website.name, website) |
| 198 | + _logger.info("Tested %s views", view_count) |
| 199 | + self.assertGreater(view_count, 2900, "Test should have checked many views") |
| 200 | + # Use this information to potentially update known possible conflicts. |
| 201 | + for known_classes in CONFLICTUAL_CLASSES: |
| 202 | + classes_inventory.difference_update(known_classes) |
| 203 | + for known_classes in CONFLICTUAL_CLASSES_RE.values(): |
| 204 | + classes_inventory.difference_update(known_classes) |
| 205 | + for known_classes_re in CONFLICTUAL_CLASSES_RE: |
| 206 | + classes_inventory = [cl for cl in filter(lambda cl: not known_classes_re.findall(cl), classes_inventory)] |
| 207 | + _logger.info("Unknown classes encountered: %r", sorted(list(classes_inventory))) |
| 208 | + self.assertFalse(errors, "No error should have been collected") |
0 commit comments