2023-10-26 16:55:34 +02:00
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2024-09-24 08:40:20 +00:00
from lxml import etree , html
2023-10-26 16:55:34 +02:00
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 ' ] ,
2024-07-17 20:52:53 +02:00
[ ' s_carousel_default ' , ' s_carousel_rounded ' ] ,
2023-10-26 16:55:34 +02:00
[ ' 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$ ' ) : [ ] ,
2024-09-06 17:29:47 +02:00
# Whitelist workaround for s_social_media inner snippet Layout: None
re . compile ( r ' ^fa-...+ ' ) : [ ' fa-stack ' ] ,
2023-10-26 16:55:34 +02:00
# Rounded
re . compile ( r ' ^rounded-.+ ' ) : [ ] ,
# Shadow
re . compile ( r ' ^shadow-.+ ' ) : [ ] ,
# Shapes
re . compile ( r ' ^o_web_editor_[A-Z].+ ' ) : [ ] ,
# Snippets
2024-12-20 11:35:13 +01:00
# 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.
2023-10-26 16:55:34 +02:00
re . compile ( r ' ^s_.* ' ) : [
' s_alert_md ' ,
2024-07-15 17:05:03 +02:00
' s_blockquote_with_icon ' , ' s_blockquote ' ,
2024-07-17 20:52:53 +02:00
' s_carousel_default ' , ' s_carousel_rounded ' , ' s_carousel_boxed ' ,
' s_carousel_indicators_dots ' , ' s_carousel_indicators_hidden ' , ' s_carousel_controllers_indicators_outside ' ,
2024-10-14 09:54:18 +02:00
' s_carousel_cards_with_img ' , ' s_carousel_cards_card ' ,
2024-07-17 20:52:53 +02:00
' s_quotes_carousel ' ,
2024-09-04 15:00:13 +02:00
' 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 ' ,
2023-10-26 16:55:34 +02:00
' s_col_no_bgcolor ' , ' s_col_no_resize ' ,
2024-07-17 20:52:53 +02:00
' 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 ' ,
2023-10-26 16:55:34 +02:00
' 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 ' ,
2024-09-06 08:32:36 +02:00
' s_progress_bar_label_hidden ' , ' s_progress_bar_label_inline ' ,
2024-10-28 12:58:28 +01:00
' s_rating_no_title ' ,
2023-10-26 16:55:34 +02:00
' s_table_of_content_vertical_navbar ' , ' s_table_of_content_navbar_sticky ' , ' s_table_of_content_navbar_wrap ' ,
' s_timeline_card ' ,
2024-07-16 15:07:26 +02:00
' 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 ' ,
2024-12-20 11:35:13 +01:00
' s_donation_btn ' , ' s_donation_custom_btn ' , ' s_newsletter_subscribe_form_input_small ' ,
2023-10-26 16:55:34 +02:00
] ,
# Text
re . compile ( r ' ^text-(?!(center|end|start|bg-|lg-)).*$ ' ) : [
2024-07-22 18:19:34 +02:00
' text-break ' , ' text-decoration-none ' , ' text-reset ' ,
2023-10-26 16:55:34 +02:00
] ,
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] ' ) :
2024-03-18 13:25:06 +01:00
classes = el . attrib [ ' class ' ] . split ( )
2023-10-26 16:55:34 +02:00
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 ) )
2024-09-24 12:16:40 +00:00
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 ' ] ) )
2024-09-26 16:24:39 +02:00
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 ) )
2023-10-26 16:55:34 +02:00
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 " )
2024-09-24 08:40:20 +00:00
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 " )