mirror of
https://github.com/odoo/design-themes.git
synced 2025-10-07 01:18:52 +07:00
6ea1b24e5e
*: theme_aviato, theme_beauty, theme_bewise, theme_bistro, theme_bookstore, theme_buzzy, theme_clean, theme_cobalt, theme_enark, theme_graphene, theme_kea, theme_kiddo, theme_monglia, theme_nano, theme_notes, theme_odoo_experts, theme_orchid, theme_paptic, theme_real_estate, theme_treehouse, theme_vehicle, theme_yes, theme_zap, test_themes This commit adapts customizations for `s_carousel_cards`. task-4094405 Part of task-4077427 closes odoo/design-themes#986 Related: odoo/odoo#183519 Related: odoo/upgrade#6692 Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
300 lines
17 KiB
Python
300 lines
17 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from lxml import etree, html
|
|
|
|
import logging
|
|
import re
|
|
|
|
from odoo.addons.website.tools import MockRequest
|
|
from odoo.tests import tagged, TransactionCase
|
|
from odoo.tools import escape_psql
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
CONFLICTUAL_CLASSES = [
|
|
['btn-outline-primary', 'btn-primary', 'btn-secondary'],
|
|
['btn-block', 'btn-outline-primary'],
|
|
['container', 'container-fluid', 'o_container_small'],
|
|
['d-block', 'd-flex', 'd-inline-block', 'd-none'],
|
|
['d-block', 'd-lg-block', 'd-md-block'],
|
|
['d-flex', 'd-md-flex'],
|
|
['flex-column', 'flex-column-reverse', 'flex-row', 'flex-row-reverse'],
|
|
['g-0', 'g-col-lg-2', 'g-col-lg-3', 'g-col-lg-4', 'g-col-lg-5', 'g-col-lg-6'],
|
|
['g-0', 'g-height-5', 'g-height-8', 'g-height-10'],
|
|
['h-100', 'o_half_screen_height', 'o_full_screen_height'],
|
|
['justify-content-center', 'justify-content-start'],
|
|
['nav-link', 'nav-pills', 'nav-tabs'],
|
|
['o_cc1', 'o_cc2', 'o_cc3', 'o_cc4', 'o_cc5'],
|
|
['o_spc-medium', 'o_spc-none', 'o_spc-small'],
|
|
['oi-arrows-h', 'oi-arrows-v', 'oi-chevron-left', 'oi-chevron-right', 'oi-search'],
|
|
['position-absolute', 'position-relative'],
|
|
['s_carousel_default', 's_carousel_rounded'],
|
|
['s_image_gallery_indicators_arrows_boxed', 's_image_gallery_indicators_arrows_rounded'],
|
|
['text-center', 'text-end', 'text-start'],
|
|
]
|
|
|
|
# For each RE, associates a whitelist
|
|
CONFLICTUAL_CLASSES_RE = {
|
|
# Align
|
|
re.compile(r'^align-(?!(self|items)-).+'): [],
|
|
re.compile(r'^align-self-.+'): [],
|
|
re.compile(r'^align-items-.+'): [],
|
|
# BG
|
|
re.compile(r'^bg(-|_)'): [
|
|
'bg_option_menu_gradient',
|
|
],
|
|
# Col
|
|
re.compile(r'^col-\d+$'): [],
|
|
re.compile(r'^col-lg-.+'): [],
|
|
re.compile(r'^offset-\d+$'): [],
|
|
re.compile(r'^offset-lg-.+'): [],
|
|
# Display
|
|
re.compile(r'^display-\d$'): [],
|
|
re.compile(r'^display-\d-fs$'): [],
|
|
# Margin, padding
|
|
re.compile(r'^m-(\d|auto)$'): [],
|
|
re.compile(r'^m(x|s)-\d$'): [],
|
|
re.compile(r'^m(x|e)-\d$'): [],
|
|
re.compile(r'^m(y|t)-\d$'): [],
|
|
re.compile(r'^m(y|b)-\d$'): [],
|
|
re.compile(r'^(p(x|s)?-?\d+|padding-.+)$'): [],
|
|
re.compile(r'^(p(x|e)?-?\d+|padding-.+)$'): [],
|
|
re.compile(r'^(p(y|t)?-?\d+|padding-.+)$'): [],
|
|
# p0+pb32 appears in Bewise and Graphene
|
|
re.compile(r'^(p(y|b)?-?\d+|padding-.+)$'): ['p0'],
|
|
# Font awesome
|
|
re.compile(r'^fa-\dx$'): [],
|
|
# Whitelist workaround for s_social_media inner snippet Layout: None
|
|
re.compile(r'^fa-...+'): ['fa-stack'],
|
|
# Rounded
|
|
re.compile(r'^rounded-.+'): [],
|
|
# Shadow
|
|
re.compile(r'^shadow-.+'): [],
|
|
# Shapes
|
|
re.compile(r'^o_web_editor_[A-Z].+'): [],
|
|
# Snippets
|
|
# TODO our convention (badly followed) for classes which are specific to a
|
|
# snippet's inner components or options is to start that class with the
|
|
# class specific to the snippet itself. For instance, for a s_some_stuff
|
|
# snippet, use s_some_stuff_button or s_some_stuff_small. The test here
|
|
# flags as wrong such an usecase... unless you explicitly whitelist it. It
|
|
# should be smarter than that and make following our convention always ok
|
|
# without the need to change this test's whitelist.
|
|
re.compile(r'^s_.*'): [
|
|
's_alert_md',
|
|
's_blockquote_with_icon', 's_blockquote',
|
|
's_carousel_default', 's_carousel_rounded', 's_carousel_boxed',
|
|
's_carousel_indicators_dots', 's_carousel_indicators_hidden', 's_carousel_controllers_indicators_outside',
|
|
's_carousel_cards_with_img', 's_carousel_cards_card',
|
|
's_quotes_carousel',
|
|
's_dynamic', 's_dynamic_empty',
|
|
's_dynamic_snippet_blog_posts', 's_blog_posts_effect_marley', 's_blog_post_big_picture', 's_blog_posts_post_picture_size_default',
|
|
's_event_upcoming_snippet', 's_event_event_picture',
|
|
's_col_no_bgcolor', 's_col_no_resize',
|
|
's_image_gallery', 's_image_gallery_indicators_arrows_boxed', 's_image_gallery_indicators_arrows_rounded',
|
|
's_image_gallery_indicators_dots', 's_image_gallery_indicators_squared', 's_image_gallery_indicators_rounded', 's_image_gallery_indicators_hidden', 's_image_gallery_indicators_bars', 's_image_gallery_indicators_outside','s_image_gallery_controllers_outside_arrows_right', 's_image_gallery_controllers_outside',
|
|
's_newsletter_list', 's_newsletter_subscribe_form',
|
|
's_parallax_is_fixed', 's_parallax_no_overflow_hidden',
|
|
's_process_steps_connector_line',
|
|
's_product_catalog_dish_name', 's_product_catalog_dish_dot_leaders',
|
|
's_progress_bar_label_hidden', 's_progress_bar_label_inline',
|
|
's_rating_no_title',
|
|
's_table_of_content_vertical_navbar', 's_table_of_content_navbar_sticky', 's_table_of_content_navbar_wrap',
|
|
's_timeline_card',
|
|
's_website_form_custom', 's_website_form_dnone', 's_website_form_field', 's_website_form_input', 's_website_form_mark', 's_website_form_submit', 's_website_form_no_submit_label',
|
|
's_donation_btn', 's_donation_custom_btn', 's_newsletter_subscribe_form_input_small',
|
|
],
|
|
# Text
|
|
re.compile(r'^text-(?!(center|end|start|bg-|lg-)).*$'): [
|
|
'text-break', 'text-decoration-none', 'text-reset',
|
|
],
|
|
re.compile(r'^text-bg-.*$'): [],
|
|
re.compile(r'^text-lg-.*$'): [],
|
|
# Width
|
|
re.compile(r'^w-\d*$'): [],
|
|
}
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestNewPageTemplates(TransactionCase):
|
|
|
|
def test_template_names(self):
|
|
websites_themes = self.env['website'].get_test_themes_websites()
|
|
for website in websites_themes:
|
|
views = self.env['ir.ui.view'].search([
|
|
('key', 'like', f'{website.theme_id.name}.new_page_template%_s_'),
|
|
])
|
|
if website.theme_id.name != 'theme_default':
|
|
self.assertGreater(len(views), 10, "Test should have encountered some views in theme %r" % website.name)
|
|
for view in views:
|
|
self.assertEqual(view.mode, 'extension', "Theme's new page template customization %r should never be primary" % view.key)
|
|
name = view.key.split('.')[1]
|
|
parent_name = view.inherit_id.key.split('.')[1]
|
|
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))
|
|
|
|
def test_render_templates(self):
|
|
errors = []
|
|
view_ids = set()
|
|
websites_themes = self.env['website'].get_test_themes_websites()
|
|
for website in websites_themes:
|
|
with MockRequest(self.env, website=website):
|
|
views = self.env['ir.ui.view'].search([
|
|
'|', '|',
|
|
('key', 'like', f'{website.theme_id.name}.s_'),
|
|
('key', 'like', f'{website.theme_id.name}.configurator'),
|
|
('key', 'like', f'{website.theme_id.name}.new_page'),
|
|
])
|
|
view_ids.update(views.ids)
|
|
for view in views:
|
|
try:
|
|
self.env['ir.qweb']._render(view.id)
|
|
except Exception:
|
|
errors.append("View %s cannot be rendered" % view.key)
|
|
_logger.info("Tested %s views", len(view_ids))
|
|
self.assertGreater(len(view_ids), 1250, "Test should have encountered a lot of views")
|
|
self.assertFalse(errors, "No error should have been collected")
|
|
|
|
def test_render_applied_templates(self):
|
|
View = self.env['ir.ui.view']
|
|
errors = []
|
|
classes_inventory = set()
|
|
view_count = 0
|
|
|
|
def check(theme_name, website):
|
|
with MockRequest(self.env, website=website):
|
|
views = View.search([
|
|
'|', '|',
|
|
('key', 'in', [
|
|
'website.snippets',
|
|
'website.new_page_template_groups',
|
|
]),
|
|
('key', 'like', escape_psql('website.configurator_')),
|
|
('key', 'like', escape_psql('website.new_page_template_sections_')),
|
|
])
|
|
for view in views:
|
|
try:
|
|
# TODO: Improve the perfs of the next line
|
|
# Doesn't seem to be a way to avoid one RECURSIVE
|
|
# SQL Query from `_get_inheriting_views` per view
|
|
html_text = self.env['ir.qweb']._render(view.id)
|
|
if not html_text:
|
|
continue
|
|
html_tree = html.fromstring(html_text)
|
|
blocks_el = html_tree.xpath("//*[@id='o_scroll']")
|
|
if blocks_el:
|
|
# Only look at blocks in website.snippets
|
|
html_tree = blocks_el[0]
|
|
for el in html_tree.xpath('//*[@class]'):
|
|
classes = el.attrib['class'].split()
|
|
classes_inventory.update(classes)
|
|
if len(classes) != len(set(classes)):
|
|
errors.append("Using %r, view %r contains duplicate classes: %r" % (theme_name, view.key, classes))
|
|
for conflicting_classes in CONFLICTUAL_CLASSES:
|
|
conflict = set(classes).intersection(conflicting_classes)
|
|
if len(conflict) > 1:
|
|
errors.append("Using %r, view %r contains conflicting classes: %r in %r" % (theme_name, view.key, conflict, classes))
|
|
for conflicting_classes_re in CONFLICTUAL_CLASSES_RE:
|
|
conflict = {cl for cl in filter(conflicting_classes_re.findall, set(classes))}
|
|
white_list = CONFLICTUAL_CLASSES_RE[conflicting_classes_re]
|
|
conflict.difference_update(white_list)
|
|
if len(conflict) > 1:
|
|
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))
|
|
for el in html_tree.xpath('//*[@style]'):
|
|
styles = el.attrib['style'].split(';')
|
|
non_empty_styles = filter(lambda style: style, styles)
|
|
property_names = list(map(lambda style: style.split(':')[0].strip(), non_empty_styles))
|
|
if len(property_names) != len(set(property_names)):
|
|
errors.append("Using %r, view %r contains duplicate style properties: %r" % (theme_name, view.key, el.attrib['style']))
|
|
for grid_el in html_tree.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' o_grid_mode ')]"):
|
|
if 'data-row-count' not in grid_el.attrib:
|
|
errors.append("Using %r, view %r defines a grid mode row without row count" % (theme_name, view.key))
|
|
continue
|
|
row_count = int(grid_el.attrib['data-row-count'])
|
|
max_row = 0
|
|
for item_el in grid_el.xpath(".//div[contains(concat(' ', normalize-space(@class), ' '), ' o_grid_item ')]"):
|
|
classes = item_el.attrib['class'].split()
|
|
styles = item_el.attrib['style'].split(';')
|
|
grid_area_style = list(filter(lambda style: style.strip().startswith('grid-area:'), styles))
|
|
if not grid_area_style:
|
|
errors.append("Using %r, view %r does not specify a grid-area for its grid item" % (theme_name, view.key))
|
|
continue
|
|
grid_area = grid_area_style[0].split(':')[1].strip()
|
|
top, left, bottom, right = map(int, grid_area.split('/'))
|
|
max_row = max(max_row, bottom)
|
|
height_class = f'g-height-{bottom - top}'
|
|
if height_class not in classes:
|
|
errors.append("Using %r, view %r does not specify %r for grid item %r (%r)" % (theme_name, view.key, height_class, grid_area, classes))
|
|
width_class = f'g-col-lg-{right - left}'
|
|
if width_class not in classes:
|
|
errors.append("Using %r, view %r does not specify %r for grid item %r (%r)" % (theme_name, view.key, width_class, grid_area, classes))
|
|
non_grid_width_class = f'col-lg-{right - left}'
|
|
if non_grid_width_class not in classes:
|
|
errors.append("Using %r, view %r does not specify %r for grid item %r (%r)" % (theme_name, view.key, non_grid_width_class, grid_area, classes))
|
|
padding_classes = list(filter(lambda klass: klass.startswith('pb') or klass.startswith('pt'), classes))
|
|
if padding_classes:
|
|
errors.append("Using %r, view %r specifies unnecessary padding classes on grid item %r" % (theme_name, view.key, padding_classes))
|
|
if row_count != max_row - 1:
|
|
errors.append("Using %r, view %r defines %r as row count while %r is reached" % (theme_name, view.key, row_count, max_row))
|
|
for el in html_tree.xpath('//*[@data-row-count]'):
|
|
classes = el.attrib['class'].split()
|
|
if 'o_grid_mode' not in classes:
|
|
errors.append("Using %r, view %r defines a row count on a non-grid mode row" % (theme_name, view.key))
|
|
except Exception:
|
|
_logger.error("Using %r, view %r cannot be rendered", theme_name, view.key)
|
|
errors.append("Using %r, view %r cannot be rendered" % (theme_name, view.key))
|
|
return len(views)
|
|
|
|
view_count += check('no theme', self.env.ref('website.default_website'))
|
|
websites_themes = self.env['website'].get_test_themes_websites()
|
|
for website in websites_themes:
|
|
view_count += check(website.name, website)
|
|
_logger.info("Tested %s views", view_count)
|
|
self.assertGreater(view_count, 2900, "Test should have checked many views")
|
|
# Use this information to potentially update known possible conflicts.
|
|
for known_classes in CONFLICTUAL_CLASSES:
|
|
classes_inventory.difference_update(known_classes)
|
|
for known_classes in CONFLICTUAL_CLASSES_RE.values():
|
|
classes_inventory.difference_update(known_classes)
|
|
for known_classes_re in CONFLICTUAL_CLASSES_RE:
|
|
classes_inventory = [cl for cl in filter(lambda cl: not known_classes_re.findall(cl), classes_inventory)]
|
|
_logger.info("Unknown classes encountered: %r", sorted(list(classes_inventory)))
|
|
self.assertFalse(errors, "No error should have been collected")
|
|
|
|
def test_attribute_separator(self):
|
|
ATTRIBUTE_SEPARATORS = {
|
|
'class': ' ',
|
|
'style': ';',
|
|
'sizes': ',',
|
|
'itemref': ' ',
|
|
}
|
|
View = self.env['ir.ui.view']
|
|
errors = []
|
|
view_count = 0
|
|
|
|
for module_name in ['website', *map(lambda website: website.theme_id.name, self.env['website'].get_test_themes_websites())]:
|
|
views = View.search([
|
|
'|', '|',
|
|
('key', 'like', escape_psql(f'{module_name}.s_')),
|
|
('key', 'like', escape_psql(f'{module_name}.configurator')),
|
|
('key', 'like', escape_psql(f'{module_name}.new_page')),
|
|
])
|
|
for view in views:
|
|
try:
|
|
xml_tree = etree.fromstring(view.arch_db)
|
|
except etree.LxmlError:
|
|
_logger.error("Using %r, view %r cannot be parsed: %r", module_name, view.key, view.arch_db)
|
|
errors.append("Using %r, view %r cannot be parsed: %r" % (module_name, view.key, view.arch_db))
|
|
continue
|
|
for el in xml_tree.xpath('//attribute[@add] | //attribute[@remove]'):
|
|
attribute_name = el.attrib['name']
|
|
if attribute_name in ATTRIBUTE_SEPARATORS:
|
|
current_separator = el.attrib.get('separator', ',')
|
|
expected_separator = ATTRIBUTE_SEPARATORS[attribute_name]
|
|
if current_separator != expected_separator:
|
|
errors.append("Using %r, view %r uses separator %r to modify attribute %r" % (module_name, view.key, current_separator, attribute_name))
|
|
view_count += len(views)
|
|
|
|
_logger.info("Tested %s views", view_count)
|
|
self.assertGreater(view_count, 2500, "Test should have checked many views")
|
|
self.assertFalse(errors, "No error should have been collected")
|