[REF] base: refactor check_xml and read_combined
View creation/edition represent a important part of an install and a lot of possible view errors are not detected, like fields used in domain filters. Some part of the code a difficult to maintain, and view checks are splitted in multiple places. This commit aims at refactoring view validation by regrouping most part of the logic in ir_ui_view and trying to optimize the overall process. Since most of the lines were touched, this task was also an opportunity to modernize the API. Main changes on method `check_xml`: - extract node processign and validation to individual postprocessor - add validation for filter node, buttons, ... - fix accessibility checks (and improve their performance) - move xpath check to specific Python node validator - clarify error messages (wip to continue) Main changes on method `read_combined`: - optimize the search for inheriting views in a single query doing the whole recursive search Indeed, after removing xpath validations, `get_inheriting_views_arch` was the most expensive method in `check_xml`, spending most of the time in `search` because of recursive calls to retrieve children views. The view Backend Assets is a good example of the latter point, since a line is added in the view for almost every module. 70 views (community) are added at first level, `get_inheriting_views_arch` is efficient and returns all 70 views. Then at the second level, the method `get_inheriting_views_arch` is called 70 times for nothing. 70 calls to `search` (squared/2 since each view is checked independently) are almost useless. The same case applies to the settings view. As a result, the average module installation time is 25% faster, and the average time spent in `read_combined` is almost divided by 2.
This commit is contained in:
committed by
Raphael Collet
parent
b63e82bebd
commit
aae1d57829
@@ -20,6 +20,7 @@
|
||||
'views/mail_message_views.xml',
|
||||
'views/mail_mail_views.xml',
|
||||
'views/mail_followers_views.xml',
|
||||
'views/mail_moderation_views.xml',
|
||||
'views/mail_channel_views.xml',
|
||||
'views/mail_shortcode_views.xml',
|
||||
'views/mail_activity_views.xml',
|
||||
@@ -35,7 +36,6 @@
|
||||
'views/mail_templates.xml',
|
||||
'wizard/email_template_preview_view.xml',
|
||||
'views/mail_template_views.xml',
|
||||
'views/mail_moderation_views.xml',
|
||||
'views/ir_actions_views.xml',
|
||||
'views/ir_model_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
|
||||
@@ -251,32 +251,24 @@ class View(models.Model):
|
||||
return view_id if view_id._name == 'ir.ui.view' else self.env['ir.ui.view']
|
||||
|
||||
@api.model
|
||||
def _get_inheriting_views_arch_website(self, view_id):
|
||||
return self.env['website'].browse(self._context.get('website_id'))
|
||||
|
||||
@api.model
|
||||
def _get_inheriting_views_arch_domain(self, view_id, model):
|
||||
domain = super(View, self)._get_inheriting_views_arch_domain(view_id, model)
|
||||
current_website = self._get_inheriting_views_arch_website(view_id)
|
||||
def _get_inheriting_views_arch_domain(self, model):
|
||||
domain = super(View, self)._get_inheriting_views_arch_domain(model)
|
||||
current_website = self.env['website'].browse(self._context.get('website_id'))
|
||||
website_views_domain = current_website.website_domain()
|
||||
# when rendering for the website we have to include inactive views
|
||||
# we will prefer inactive website-specific views over active generic ones
|
||||
if current_website:
|
||||
domain = [leaf for leaf in domain if 'active' not in leaf]
|
||||
|
||||
return expression.AND([website_views_domain, domain])
|
||||
|
||||
@api.model
|
||||
def get_inheriting_views_arch(self, view_id, model):
|
||||
def get_inheriting_views_arch(self, model):
|
||||
if not self._context.get('website_id'):
|
||||
return super(View, self).get_inheriting_views_arch(view_id, model)
|
||||
|
||||
inheriting_views = super(View, self.with_context(active_test=False)).get_inheriting_views_arch(view_id, model)
|
||||
return super(View, self).get_inheriting_views_arch(model)
|
||||
|
||||
views = super(View, self.with_context(active_test=False)).get_inheriting_views_arch(model)
|
||||
# prefer inactive website-specific views over active generic ones
|
||||
inheriting_views = self.browse([view[1] for view in inheriting_views]).filter_duplicate().filtered('active')
|
||||
|
||||
return [(view.arch, view.id) for view in inheriting_views]
|
||||
return views.filter_duplicate().filtered('active')
|
||||
|
||||
@api.model
|
||||
@tools.ormcache_context('self.env.uid', 'self.env.su', 'xml_id', keys=('website_id',))
|
||||
|
||||
@@ -185,9 +185,8 @@ class Website(models.Model):
|
||||
attachments_to_unlink.unlink()
|
||||
return super(Website, self).unlink()
|
||||
|
||||
@api.model
|
||||
def create_and_redirect_to_theme(self, vals):
|
||||
self.browse(vals)._force()
|
||||
def create_and_redirect_to_theme(self):
|
||||
self._force()
|
||||
action = self.env.ref('website.theme_install_kanban_action')
|
||||
return action.read()[0]
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,7 @@ class FormatAddressMixin(models.AbstractModel):
|
||||
#(e.g fields not present on the model). In that case we just return arch
|
||||
if self._name != 'res.partner':
|
||||
try:
|
||||
self.env['ir.ui.view'].postprocess_and_fields(self._name, sub_view_node, None)
|
||||
self.env['ir.ui.view'].postprocess_and_fields(sub_view_node, model=self._name)
|
||||
except ValueError:
|
||||
return arch
|
||||
address_node.getparent().replace(address_node, sub_view_node)
|
||||
|
||||
@@ -35,7 +35,6 @@ from . import test_search
|
||||
from . import test_translate
|
||||
#import test_uninstall # loop
|
||||
from . import test_user_has_group
|
||||
from . import test_view_validation
|
||||
from . import test_views
|
||||
from . import test_xmlrpc
|
||||
from . import test_res_partner
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from lxml import etree
|
||||
import io
|
||||
|
||||
from odoo.tests.common import BaseCase
|
||||
from odoo.tools.view_validation import (
|
||||
valid_page_in_book, valid_att_in_form, valid_type_in_colspan,
|
||||
valid_type_in_col, valid_att_in_field, valid_att_in_label,
|
||||
valid_field_in_graph, valid_field_in_tree, valid_alternative_image_text,
|
||||
valid_simili_button,
|
||||
valid_simili_progressbar, valid_dialog, valid_simili_dropdown,
|
||||
valid_focusable_button, valid_prohibited_none_role, valid_simili_tabpanel,
|
||||
valid_simili_tab, valid_simili_tablist, valid_alerts
|
||||
)
|
||||
|
||||
invalid_form = etree.parse(io.BytesIO(b'''\
|
||||
<form>
|
||||
<label></label>
|
||||
<ul class="dropdown-menu"><li/><li/></ul>
|
||||
<div role="presentation"/>
|
||||
<group>
|
||||
<div>
|
||||
<page></page>
|
||||
<label colspan="True" string=""></label>
|
||||
<field></field>
|
||||
</div>
|
||||
</group>
|
||||
<notebook>
|
||||
<page>
|
||||
<group col="Two">
|
||||
<div>
|
||||
<div class="o_progressbar">100%</div>
|
||||
<label string=""></label>
|
||||
<img/>
|
||||
<span class="fa fa-warning"/>
|
||||
<field colspan="Five"> </field>
|
||||
</div>
|
||||
</group>
|
||||
<a class="btn"/>
|
||||
<div class="btn"/>
|
||||
<div class="tab-pane"/>
|
||||
</page>
|
||||
</notebook>
|
||||
<div class="modal"/>
|
||||
<a data-toggle="tab"/>
|
||||
<div class="nav-tabs"/>
|
||||
<div class="alert alert-success"/>
|
||||
</form>
|
||||
''')).getroot()
|
||||
|
||||
valid_form = etree.parse(io.BytesIO(b'''\
|
||||
<form string="">
|
||||
<field name=""></field>
|
||||
<field name=""></field>
|
||||
<ul class="dropdown-menu" role="menu"></ul>
|
||||
<notebook>
|
||||
<page>
|
||||
<field name=""></field>
|
||||
<label for="" string=""></label>
|
||||
<field name=""></field>
|
||||
</page>
|
||||
<page>
|
||||
<group colspan="5" col="2">
|
||||
<div class="o_progressbar" role="progressbar" aria-valuenow="14" aria-valuemin="0" aria-valuemax="100">14%</div>
|
||||
<label for=""></label>
|
||||
<label for="" string="" colspan="5"></label>
|
||||
<img alt="Test image"/>
|
||||
<span class="fa fa-success" aria-label="Test span" title="Test span"/>
|
||||
<a class="fa fa-success"><span aria-label="test" title="test"/></a>
|
||||
<a class="btn" role="button"/>
|
||||
<i class="fa fa-check"/> Test icon
|
||||
<i class="fa fa-check"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
<div role="dialog" class="modal">
|
||||
<header class="modal-header"/>
|
||||
<main class="modal-body"/>
|
||||
<i class="fa fa-check"/> <span>Test</span>
|
||||
<footer class="modal-footer"/>
|
||||
</div>
|
||||
<div class="tab-pane" role="tabpanel"/>
|
||||
<a data-toggle="tab" role="tab" aria-selected="true" aria-controls="test"/>
|
||||
<div class="nav-tabs" role="tablist"/>
|
||||
<div class="alert alert-success" role="alert"/>
|
||||
<div class="alert alert-success" role="alertdialog"/>
|
||||
<div class="alert alert-success" role="status"/>
|
||||
</form>
|
||||
''')).getroot()
|
||||
|
||||
invalid_graph = etree.parse(io.BytesIO(b'''\
|
||||
<graph>
|
||||
<label/>
|
||||
<group>
|
||||
<div>
|
||||
<field></field>
|
||||
<field></field>
|
||||
</div>
|
||||
</group>
|
||||
</graph>
|
||||
''')).getroot()
|
||||
|
||||
valid_graph = etree.parse(io.BytesIO(b'''\
|
||||
<graph string="">
|
||||
<field name=""></field>
|
||||
<field name=""></field>
|
||||
</graph>
|
||||
''')).getroot()
|
||||
|
||||
invalid_tree = etree.parse(io.BytesIO(b'''\
|
||||
<tree>
|
||||
<group>
|
||||
<div>
|
||||
<field></field>
|
||||
<field></field>
|
||||
</div>
|
||||
</group>
|
||||
</tree>
|
||||
''')).getroot()
|
||||
|
||||
valid_tree = etree.parse(io.BytesIO(b'''\
|
||||
<tree string="">
|
||||
<field name=""></field>
|
||||
<field name=""></field>
|
||||
<button/>
|
||||
<field name=""></field>
|
||||
</tree>
|
||||
''')).getroot()
|
||||
|
||||
|
||||
class TestViewValidation(BaseCase):
|
||||
""" Test the view validation code (but not the views themselves). """
|
||||
|
||||
def test_page_validation(self):
|
||||
assert not valid_page_in_book(invalid_form)
|
||||
assert valid_page_in_book(valid_form)
|
||||
|
||||
def test_all_field_validation(self):
|
||||
assert not valid_att_in_field(invalid_form)
|
||||
assert valid_att_in_field(valid_form)
|
||||
|
||||
def test_all_label_validation(self):
|
||||
assert not valid_att_in_label(invalid_form)
|
||||
assert valid_att_in_label(valid_form)
|
||||
|
||||
def test_form_string_validation(self):
|
||||
assert valid_att_in_form(valid_form)
|
||||
|
||||
def test_graph_validation(self):
|
||||
assert not valid_field_in_graph(invalid_graph)
|
||||
assert valid_field_in_graph(valid_graph)
|
||||
|
||||
def test_tree_validation(self):
|
||||
assert not valid_field_in_tree(invalid_tree)
|
||||
assert valid_field_in_tree(valid_tree)
|
||||
|
||||
def test_colspan_datatype_validation(self):
|
||||
assert not valid_type_in_colspan(invalid_form)
|
||||
assert valid_type_in_colspan(valid_form)
|
||||
|
||||
def test_col_datatype_validation(self):
|
||||
assert not valid_type_in_col(invalid_form)
|
||||
assert valid_type_in_col(valid_form)
|
||||
|
||||
def test_a11y_validation(self):
|
||||
assert valid_alternative_image_text(invalid_form) == "Warning"
|
||||
assert valid_alternative_image_text(valid_form) is True
|
||||
assert valid_simili_button(invalid_form) == "Warning"
|
||||
assert valid_simili_button(valid_form) is True
|
||||
assert valid_dialog(invalid_form) == "Warning"
|
||||
assert valid_dialog(valid_form) is True
|
||||
assert valid_simili_dropdown(invalid_form) == "Warning"
|
||||
assert valid_simili_dropdown(valid_form) is True
|
||||
assert valid_simili_progressbar(invalid_form) == "Warning"
|
||||
assert valid_simili_progressbar(valid_form) is True
|
||||
assert valid_simili_tabpanel(invalid_form) == "Warning"
|
||||
assert valid_simili_tabpanel(valid_form) is True
|
||||
assert valid_simili_tablist(invalid_form) == "Warning"
|
||||
assert valid_simili_tablist(valid_form) is True
|
||||
assert valid_simili_tab(invalid_form) == "Warning"
|
||||
assert valid_simili_tab(valid_form) is True
|
||||
assert valid_focusable_button(invalid_form) == "Warning"
|
||||
assert valid_focusable_button(valid_form) is True
|
||||
assert valid_prohibited_none_role(invalid_form) == "Warning"
|
||||
assert valid_prohibited_none_role(valid_form) is True
|
||||
assert valid_alerts(invalid_form) == "Warning"
|
||||
assert valid_alerts(valid_form) is True
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import ast
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
@@ -10,7 +11,7 @@ from psycopg2 import IntegrityError
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import common
|
||||
from odoo.tools import mute_logger
|
||||
from odoo.tools import mute_logger, view_validation
|
||||
from odoo.addons.base.models.ir_ui_view import (
|
||||
transfer_field_to_modifiers, transfer_node_to_modifiers, simplify_modifiers,
|
||||
)
|
||||
@@ -171,7 +172,7 @@ class TestViewInheritance(ViewCase):
|
||||
'inherit_id': parent,
|
||||
'priority': 5, # higher than default views
|
||||
})
|
||||
self.view_ids[name] = view.id
|
||||
self.view_ids[name] = view
|
||||
return view
|
||||
|
||||
def setUp(self):
|
||||
@@ -182,42 +183,35 @@ class TestViewInheritance(ViewCase):
|
||||
self.model = 'ir.ui.view.custom'
|
||||
self.view_ids = {}
|
||||
|
||||
a = self.makeView("A")
|
||||
a1 = self.makeView("A1", a.id)
|
||||
a11 = self.makeView("A11", a1.id)
|
||||
self.makeView("A111", a11.id)
|
||||
self.makeView("A12", a1.id)
|
||||
a2 = self.makeView("A2", a.id)
|
||||
self.makeView("A21", a2.id)
|
||||
a22 = self.makeView("A22", a2.id)
|
||||
self.makeView("A221", a22.id)
|
||||
self.a = self.makeView("A")
|
||||
self.a1 = self.makeView("A1", self.a.id)
|
||||
self.a2 = self.makeView("A2", self.a.id)
|
||||
self.a11 = self.makeView("A11", self.a1.id)
|
||||
self.a11.mode = 'primary'
|
||||
self.makeView("A111", self.a11.id)
|
||||
self.makeView("A12", self.a1.id)
|
||||
self.makeView("A21", self.a2.id)
|
||||
self.a22 = self.makeView("A22", self.a2.id)
|
||||
self.makeView("A221", self.a22.id)
|
||||
|
||||
b = self.makeView('B', arch=self.arch_for("B", 'tree'))
|
||||
self.makeView('B1', b.id, arch=self.arch_for("B1", 'tree', parent=b))
|
||||
c = self.makeView('C', arch=self.arch_for("C", 'tree'))
|
||||
c.write({'priority': 1})
|
||||
self.b = self.makeView('B', arch=self.arch_for("B", 'tree'))
|
||||
self.makeView('B1', self.b.id, arch=self.arch_for("B1", 'tree', parent=self.b))
|
||||
self.c = self.makeView('C', arch=self.arch_for("C", 'tree'))
|
||||
self.c.write({'priority': 1})
|
||||
|
||||
def test_get_inheriting_views_arch(self):
|
||||
self.assertEqual(
|
||||
self.View.get_inheriting_views_arch(self.view_ids['A'], self.model), [
|
||||
(self.arch_for('A1', parent=True), self.view_ids['A1']),
|
||||
(self.arch_for('A2', parent=True), self.view_ids['A2']),
|
||||
])
|
||||
|
||||
self.assertEqual(
|
||||
self.View.get_inheriting_views_arch(self.view_ids['A21'], self.model),
|
||||
[])
|
||||
|
||||
self.assertEqual(
|
||||
self.View.get_inheriting_views_arch(self.view_ids['A11'], self.model),
|
||||
[(self.arch_for('A111', parent=True), self.view_ids['A111'])])
|
||||
self.view_ids['A'].get_inheriting_views_arch(self.model),
|
||||
self.view_ids['A1'] | self.view_ids['A2'] | self.view_ids['A12'] | self.view_ids['A21'] | self.view_ids['A22'] | self.view_ids['A221'])
|
||||
self.assertEqual(self.view_ids['A21'].get_inheriting_views_arch(self.model), self.View)
|
||||
self.assertEqual(self.view_ids['A11'].get_inheriting_views_arch(self.model), self.view_ids['A111'])
|
||||
|
||||
def test_default_view(self):
|
||||
default = self.View.default_view(model=self.model, view_type='form')
|
||||
self.assertEqual(default, self.view_ids['A'])
|
||||
self.assertEqual(default, self.view_ids['A'].id)
|
||||
|
||||
default_tree = self.View.default_view(model=self.model, view_type='tree')
|
||||
self.assertEqual(default_tree, self.view_ids['C'])
|
||||
self.assertEqual(default_tree, self.view_ids['C'].id)
|
||||
|
||||
def test_no_default_view(self):
|
||||
self.assertFalse(self.View.default_view(model='does.not.exist', view_type='form'))
|
||||
@@ -260,7 +254,7 @@ class TestApplyInheritanceSpecs(ViewCase):
|
||||
E.field(name="replacement"),
|
||||
name="target", position="replace")
|
||||
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
self.assertEqual(
|
||||
self.base_arch,
|
||||
@@ -269,7 +263,7 @@ class TestApplyInheritanceSpecs(ViewCase):
|
||||
def test_delete(self):
|
||||
spec = E.field(name="target", position="replace")
|
||||
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
self.assertEqual(
|
||||
self.base_arch,
|
||||
@@ -280,7 +274,7 @@ class TestApplyInheritanceSpecs(ViewCase):
|
||||
E.field(name="inserted"),
|
||||
name="target", position="after")
|
||||
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
self.assertEqual(
|
||||
self.base_arch,
|
||||
@@ -295,7 +289,7 @@ class TestApplyInheritanceSpecs(ViewCase):
|
||||
E.field(name="inserted"),
|
||||
name="target", position="before")
|
||||
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
self.assertEqual(
|
||||
self.base_arch,
|
||||
@@ -308,8 +302,8 @@ class TestApplyInheritanceSpecs(ViewCase):
|
||||
default = E.field(E.field(name="inserted"), name="target")
|
||||
spec = E.field(E.field(name="inserted 2"), name="target", position='inside')
|
||||
|
||||
self.View.apply_inheritance_specs(self.base_arch, default, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, default)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
self.assertEqual(
|
||||
self.base_arch,
|
||||
@@ -328,7 +322,7 @@ class TestApplyInheritanceSpecs(ViewCase):
|
||||
E.field(E.field(name="inserted 3"), name="target"),
|
||||
)
|
||||
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
self.assertEqual(
|
||||
self.base_arch,
|
||||
@@ -348,7 +342,7 @@ class TestApplyInheritanceSpecs(ViewCase):
|
||||
name="target", position="serious_series")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_incorrect_version(self):
|
||||
@@ -359,14 +353,14 @@ class TestApplyInheritanceSpecs(ViewCase):
|
||||
foo="42", version="7.0")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.View.apply_inheritance_specs(arch, spec, None)
|
||||
self.View.apply_inheritance_specs(arch, spec)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_target_not_found(self):
|
||||
spec = E.field(name="targut")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
|
||||
class TestApplyInheritanceWrapSpecs(ViewCase):
|
||||
@@ -375,7 +369,7 @@ class TestApplyInheritanceWrapSpecs(ViewCase):
|
||||
self.base_arch = E.template(E.div(E.p("Content")))
|
||||
|
||||
def apply_spec(self, spec):
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec, None)
|
||||
self.View.apply_inheritance_specs(self.base_arch, spec)
|
||||
|
||||
def test_replace(self):
|
||||
spec = E.xpath(
|
||||
@@ -404,7 +398,7 @@ class TestApplyInheritanceMoveSpecs(ViewCase):
|
||||
)
|
||||
|
||||
def apply_spec(self, arch, spec):
|
||||
self.View.apply_inheritance_specs(arch, spec, None)
|
||||
self.View.apply_inheritance_specs(arch, spec)
|
||||
|
||||
def test_move_replace(self):
|
||||
spec = E.xpath(
|
||||
@@ -1039,7 +1033,7 @@ class TestViews(ViewCase):
|
||||
<form string="Base title">
|
||||
<separator name="separator" string="Separator" colspan="4"/>
|
||||
<footer>
|
||||
<button name="action_next" type="object" string="Next button" class="btn-primary"/>
|
||||
<button name="action_archive" type="object" string="Next button" class="btn-primary"/>
|
||||
<button string="Skip" special="cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -1056,7 +1050,7 @@ class TestViews(ViewCase):
|
||||
</form>
|
||||
<footer position="replace">
|
||||
<footer>
|
||||
<button name="action_next" type="object" string="New button"/>
|
||||
<button name="action_archive" type="object" string="New button"/>
|
||||
</footer>
|
||||
</footer>
|
||||
<separator name="separator" position="replace">
|
||||
@@ -1091,7 +1085,7 @@ class TestViews(ViewCase):
|
||||
E.form(
|
||||
E.p("Replacement data"),
|
||||
E.footer(
|
||||
E.button(name="action_next", type="object", string="New button"),
|
||||
E.button(name="action_archive", type="object", string="New button"),
|
||||
thing="bob lolo bibi and co", otherthing="lolo"
|
||||
),
|
||||
string="Replacement title"))
|
||||
@@ -1164,7 +1158,7 @@ class TestViews(ViewCase):
|
||||
<form string="Base title">
|
||||
<separator name="separator" string="Separator" colspan="4"/>
|
||||
<footer>
|
||||
<button name="action_next" type="object" string="Next button" class="btn-primary"/>
|
||||
<button name="action_archive" type="object" string="Next button" class="btn-primary"/>
|
||||
<button string="Skip" special="cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -1181,7 +1175,7 @@ class TestViews(ViewCase):
|
||||
</form>
|
||||
<footer position="replace">
|
||||
<footer>
|
||||
<button name="action_next" type="object" string="New button"/>
|
||||
<button name="action_unarchive" type="object" string="New button"/>
|
||||
</footer>
|
||||
</footer>
|
||||
<separator name="separator" position="replace">
|
||||
@@ -1213,7 +1207,7 @@ class TestViews(ViewCase):
|
||||
E.form(
|
||||
E.p("Replacement data"),
|
||||
E.footer(
|
||||
E.button(name="action_next", type="object", string="New button")),
|
||||
E.button(name="action_unarchive", type="object", string="New button")),
|
||||
string="Replacement title"
|
||||
))
|
||||
|
||||
@@ -1229,6 +1223,7 @@ class TestViews(ViewCase):
|
||||
transfer_field_to_modifiers(what, modifiers)
|
||||
simplify_modifiers(modifiers)
|
||||
assert modifiers == expected, "%s != %s" % (modifiers, expected)
|
||||
|
||||
_test_modifiers('<field name="a"/>', {})
|
||||
_test_modifiers('<field name="a" invisible="1"/>', {"invisible": True})
|
||||
_test_modifiers('<field name="a" readonly="1"/>', {"readonly": True})
|
||||
@@ -1237,12 +1232,22 @@ class TestViews(ViewCase):
|
||||
_test_modifiers('<field name="a" readonly="0"/>', {})
|
||||
_test_modifiers('<field name="a" required="0"/>', {})
|
||||
# TODO: Order is not guaranteed
|
||||
_test_modifiers('<field name="a" invisible="1" required="1"/>',
|
||||
{"invisible": True, "required": True})
|
||||
_test_modifiers('<field name="a" invisible="1" required="0"/>', {"invisible": True})
|
||||
_test_modifiers('<field name="a" invisible="0" required="1"/>', {"required": True})
|
||||
_test_modifiers("""<field name="a" attrs="{'invisible': [['b', '=', 'c']]}"/>""",
|
||||
{"invisible": [["b", "=", "c"]]})
|
||||
_test_modifiers(
|
||||
'<field name="a" invisible="1" required="1"/>',
|
||||
{"invisible": True, "required": True},
|
||||
)
|
||||
_test_modifiers(
|
||||
'<field name="a" invisible="1" required="0"/>',
|
||||
{"invisible": True},
|
||||
)
|
||||
_test_modifiers(
|
||||
'<field name="a" invisible="0" required="1"/>',
|
||||
{"required": True},
|
||||
)
|
||||
_test_modifiers(
|
||||
"""<field name="a" attrs="{'invisible': [['b', '=', 'c']]}"/>""",
|
||||
{"invisible": [["b", "=", "c"]]},
|
||||
)
|
||||
|
||||
# The dictionary is supposed to be the result of fields_get().
|
||||
_test_modifiers({}, {})
|
||||
@@ -1251,17 +1256,17 @@ class TestViews(ViewCase):
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_invalid_field(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.View.create({
|
||||
'name': 'invalid field',
|
||||
'model': 'ir.ui.view',
|
||||
'arch': """
|
||||
<form string="View">
|
||||
<field name="name"/>
|
||||
<field name="not_a_field"/>
|
||||
</form>
|
||||
""",
|
||||
})
|
||||
self.assertInvalid("""
|
||||
<form string="View">
|
||||
<field name="name"/>
|
||||
<field name="not_a_field"/>
|
||||
</form>
|
||||
""", 'Field "not_a_field" does not exist in model "ir.ui.view"')
|
||||
self.assertInvalid("""
|
||||
<form string="View">
|
||||
<field/>
|
||||
</form>
|
||||
""", 'Field tag must have a "name" attribute defined')
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_invalid_subfield(self):
|
||||
@@ -1407,6 +1412,33 @@ class TestViews(ViewCase):
|
||||
'arch': arch % ('', '', '<field name="model"/>'),
|
||||
})
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_domain_id_case(self):
|
||||
# id is read by default and should be usable in domains
|
||||
self.assertValid("""
|
||||
<form string="View">
|
||||
<field name="inherit_id" domain="[('id', '=', False)]"/>
|
||||
</form>
|
||||
""")
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_domain_boolean_case(self):
|
||||
arch = """
|
||||
<form string="View">
|
||||
%s
|
||||
<field name="inherit_id" domain="[(%s, '=', %s)]"/>
|
||||
</form>
|
||||
"""
|
||||
self.assertValid(arch % ('', '1', '1'))
|
||||
self.assertValid(arch % ('', '0', '1'))
|
||||
# self.assertInvalid(arch % ('', '1', '0'))
|
||||
self.assertValid(arch % ('<field name="name"/>', '0 if name else 1', '1'))
|
||||
# self.assertInvalid(arch % ('<field name="name"/><field name="type"/>', "'tata' if name else 'tutu'", 'type'), 'xxxx')
|
||||
self.assertInvalid(
|
||||
arch % ('', '0 if name else 1', '1'),
|
||||
"""Field name used in domain of <field name="inherit_id"> ([(0 if name else 1, '=', 1)]) must be present in view but is missing""",
|
||||
)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_domain_in_view(self):
|
||||
arch = """
|
||||
@@ -1427,6 +1459,39 @@ class TestViews(ViewCase):
|
||||
'arch': arch % '',
|
||||
})
|
||||
|
||||
def test_domain_unknown_field(self):
|
||||
self.assertInvalid("""
|
||||
<form string="View">
|
||||
<field name="name"/>
|
||||
<field name="inherit_id" domain="[('invalid_field', '=', 'res.users')]"/>
|
||||
</form>
|
||||
""",
|
||||
'''Unknow field "ir.ui.view.invalid_field" in domain of <field name="inherit_id"> "[('invalid_field', '=', 'res.users')]"''',
|
||||
)
|
||||
|
||||
def test_domain_field_searchable(self):
|
||||
arch = """
|
||||
<form string="View">
|
||||
<field name="name"/>
|
||||
<field name="inherit_id" domain="[('%s', '=', 'test')]"/>
|
||||
</form>
|
||||
"""
|
||||
# computed field with a search method
|
||||
self.assertValid(arch % 'model_data_id')
|
||||
# computed field, not stored, no search
|
||||
self.assertInvalid(
|
||||
arch % 'xml_id',
|
||||
'''Unsearchable field "ir.ui.view.xml_id" in path 'xml_id' in domain of <field name="inherit_id"> ="[('xml_id', '=', 'test')]"''',
|
||||
)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_domain_field_no_comodel(self):
|
||||
self.assertInvalid("""
|
||||
<form string="View">
|
||||
<field name="name" domain="[('test', '=', 'test')]"/>
|
||||
</form>
|
||||
""", "Domain on field without comodel makes no sense for \"name\" (domain:[('test', '=', 'test')])")
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_domain_in_subview(self):
|
||||
arch = """
|
||||
@@ -1468,25 +1533,30 @@ class TestViews(ViewCase):
|
||||
<field name="name"/>%s
|
||||
<field name="inherit_id" domain="[('model', '=', parent.model)]"/>
|
||||
</form>
|
||||
</field>
|
||||
</field>%s
|
||||
</form>
|
||||
"""
|
||||
self.View.create({
|
||||
'name': 'valid domain',
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch % ('<field name="model"/>', ''),
|
||||
'arch': arch % ('<field name="model"/>', '', ''),
|
||||
})
|
||||
self.View.create({
|
||||
'name': 'valid domain',
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch % ('', '', '<field name="model"/>'),
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
self.View.create({
|
||||
'name': 'valid domain',
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch % ('', ''),
|
||||
'arch': arch % ('', '', ''),
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
self.View.create({
|
||||
'name': 'valid domain',
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch % ('', '<field name="model"/>'),
|
||||
'arch': arch % ('', '<field name="model"/>', ''),
|
||||
})
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
@@ -1666,6 +1736,119 @@ class TestViews(ViewCase):
|
||||
'arch': arch % '',
|
||||
})
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_modifier_attribute_is_boolean(self):
|
||||
arch = """
|
||||
<form string="View">
|
||||
<field name="name" readonly="%s"/>
|
||||
</form>
|
||||
"""
|
||||
self.assertValid(arch % '1')
|
||||
self.assertValid(arch % '0')
|
||||
self.assertValid(arch % 'True')
|
||||
self.assertInvalid(
|
||||
arch % "[('model', '=', '1')]",
|
||||
"Attribute readonly evaluation must give a boolean, got [('model', '=', '1')]",
|
||||
)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_domain_in_filter(self):
|
||||
arch = """
|
||||
<search string="Search">
|
||||
<field name="%s"/>
|
||||
<filter string="Dummy" name="draft" domain="[('%s', '=', 'dummy')]"/>
|
||||
</search>
|
||||
"""
|
||||
self.assertValid(arch % ('name', 'name'))
|
||||
self.assertValid(arch % ('name', 'inherit_children_ids.name'))
|
||||
self.assertInvalid(
|
||||
arch % ('invalid_field', 'name'),
|
||||
'Field "invalid_field" does not exist in model "ir.ui.view"',
|
||||
)
|
||||
self.assertInvalid(
|
||||
arch % ('name', 'invalid_field'),
|
||||
"""Unknow field "ir.ui.view.invalid_field" in domain of <filter name="draft"> "[('invalid_field', '=', 'dummy')]""",
|
||||
)
|
||||
self.assertInvalid(
|
||||
arch % ('name', 'inherit_children_ids.invalid_field'),
|
||||
"""Unknow field "ir.ui.view.invalid_field" in domain of <filter name="draft"> "[('inherit_children_ids.invalid_field', '=', 'dummy')]""",
|
||||
)
|
||||
# todo add check for non searchable fields and group by
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_group_by_in_filter(self):
|
||||
arch = """
|
||||
<search string="Search">
|
||||
<filter string="Date" name="month" domain="[]" context="{'group_by':'%s'}"/>
|
||||
</search>
|
||||
"""
|
||||
self.assertValid(arch % 'name')
|
||||
self.assertInvalid(
|
||||
arch % 'invalid_field',
|
||||
"""Unknow field "invalid_field" in "group_by" value in context="{'group_by':'invalid_field'}""",
|
||||
)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_domain_invalid_in_filter(self):
|
||||
# invalid domain: it should be a list of tuples
|
||||
self.assertInvalid(
|
||||
""" <search string="Search">
|
||||
<filter string="Dummy" name="draft" domain="['name', '=', 'dummy']"/>
|
||||
</search>
|
||||
""",
|
||||
"""Invalid domain format while checking ['name', '=', 'dummy'] in domain of <filter name="draft">""",
|
||||
)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_searchpanel(self):
|
||||
arch = """
|
||||
<search>
|
||||
%s
|
||||
<searchpanel>
|
||||
%s
|
||||
<field select="multi" name="groups_id" domain="[['%s', '=', %s]]"/>
|
||||
</searchpanel>
|
||||
</search>
|
||||
"""
|
||||
self.assertValid(arch % ('', '<field name="inherit_id"/>', 'view_access', 'inherit_id'))
|
||||
self.assertInvalid(
|
||||
arch % ('<field name="inherit_id"/>', '', 'view_access', 'inherit_id'),
|
||||
"""Field inherit_id used in domain of <field name="groups_id"> ([['view_access', '=', inherit_id]]) must be present in view but is missing.""",
|
||||
)
|
||||
self.assertInvalid(
|
||||
arch % ('', '<field name="inherit_id"/>', 'view_access', 'view_access'),
|
||||
"""Field view_access used in domain of <field name="groups_id"> ([['view_access', '=', view_access]]) must be present in view but is missing.""",
|
||||
)
|
||||
self.assertInvalid(
|
||||
arch % ('', '<field name="inherit_id"/>', 'inherit_id', 'inherit_id'),
|
||||
"""Unknow field "res.groups.inherit_id" in domain of <field name="groups_id"> "[['inherit_id', '=', inherit_id]]""",
|
||||
)
|
||||
self.assertInvalid(
|
||||
arch % ('', '<field name="inherit_id" select="multi"/>', 'view_access', 'inherit_id'),
|
||||
"""Field inherit_id used in domain of <field name="groups_id"> ([['view_access', '=', inherit_id]]) is present in view but is in select multi.""",
|
||||
)
|
||||
|
||||
arch = """
|
||||
<search>
|
||||
<searchpanel>
|
||||
<field name="inherit_id"/>
|
||||
</searchpanel>
|
||||
<searchpanel>
|
||||
<field name="inherit_id"/>
|
||||
</searchpanel>
|
||||
</search>
|
||||
"""
|
||||
self.assertInvalid(arch, "Search tag can only contains one search panel")
|
||||
|
||||
def test_groups_field(self):
|
||||
arch = """
|
||||
<form string="View">
|
||||
<field name="name" groups="%s"/>
|
||||
</form>
|
||||
"""
|
||||
self.assertValid(arch % 'base.group_no_one')
|
||||
self.assertWarning(arch % 'base.dummy')
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_attrs_field(self):
|
||||
arch = """
|
||||
@@ -1687,6 +1870,21 @@ class TestViews(ViewCase):
|
||||
'arch': arch % '',
|
||||
})
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_attrs_invalid_domain(self):
|
||||
arch = """
|
||||
<form string="View">
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
<field name="inherit_id"
|
||||
attrs="{'readonly': [('model', 'ir.ui.view')]}"/>
|
||||
</form>
|
||||
"""
|
||||
self.assertInvalid(
|
||||
arch,
|
||||
"""Invalid domain format while checking {'readonly': [('model', 'ir.ui.view')]} in attrs.readonly""",
|
||||
)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_attrs_subfield(self):
|
||||
arch = """
|
||||
@@ -1751,27 +1949,60 @@ class TestViews(ViewCase):
|
||||
'arch': arch % ('', '<field name="model"/>'),
|
||||
})
|
||||
|
||||
def test_button(self):
|
||||
arch = """
|
||||
<form>
|
||||
<button type="object" name="%s"/>
|
||||
</form>
|
||||
"""
|
||||
self.assertValid(arch % 'action_archive', name='valid button name')
|
||||
self.assertInvalid(
|
||||
arch % 'wtfzzz', 'wtfzzz is not a valid action on ir.ui.view',
|
||||
name='button name is not even a method',
|
||||
)
|
||||
self.assertInvalid(
|
||||
arch % '_check_xml',
|
||||
'_check_xml on ir.ui.view is private and cannot be called from a button',
|
||||
name='button name is a private method',
|
||||
)
|
||||
self.assertWarning(arch % 'postprocess_and_fields', name='button name is a method that requires extra arguments')
|
||||
arch = """
|
||||
<form>
|
||||
<button type="action" name="%s"/>
|
||||
</form>
|
||||
"""
|
||||
self.assertInvalid(arch % 0, 'Action 0 (id: 0) does not exist for button of type action.')
|
||||
self.assertInvalid(arch % 'base.random_xmlid', 'Invalid xmlid base.random_xmlid for button of type action')
|
||||
self.assertInvalid('<form><button type="action"/></form>', 'Button must have a name')
|
||||
self.assertInvalid('<form><button special="dummy"/></form>', "Invalid special 'dummy' in button")
|
||||
self.assertValid(arch % 'base.action_server_module_immediate_install')
|
||||
self.assertInvalid(arch % 'base.partner_root', "base.partner_root is of type res.partner, expected a subclass of ir.actions.actions")
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_tree(self):
|
||||
arch = """
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<button type='object' name="action_archive"/>
|
||||
%s
|
||||
</tree>
|
||||
"""
|
||||
self.assertValid(arch % '')
|
||||
self.assertInvalid(arch % '<group/>', "Tree child can only be have one of field, button, control, groupby, widget tag (not group)")
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_tree_groupby(self):
|
||||
arch = """
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<groupby name="%s">
|
||||
<button type="object" name="method1"/>
|
||||
<button type="object" name="action_archive"/>
|
||||
</groupby>
|
||||
</tree>
|
||||
"""
|
||||
self.View.create({
|
||||
'name': 'valid groupby',
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch % ('model_data_id'),
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
self.View.create({
|
||||
'name': 'invalid groupby',
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch % ('type'),
|
||||
})
|
||||
self.assertValid(arch % ('model_data_id'))
|
||||
self.assertInvalid(arch % ('type'), "field 'type' found in 'groupby' node can only be of type many2one, found selection")
|
||||
self.assertInvalid(arch % ('dummy'), "field 'dummy' found in 'groupby' node does not exist in model ir.ui.view")
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_ui_view')
|
||||
def test_tree_groupby_many2one(self):
|
||||
@@ -1781,7 +2012,7 @@ class TestViews(ViewCase):
|
||||
%s
|
||||
<groupby name="model_data_id">
|
||||
%s
|
||||
<button type="object" name="method" attrs="{'invisible': [('noupdate', '=', True)]}" string="Button1"/>
|
||||
<button type="object" name="action_archive" attrs="{'invisible': [('noupdate', '=', True)]}" string="Button1"/>
|
||||
</groupby>
|
||||
</tree>
|
||||
"""
|
||||
@@ -1809,6 +2040,183 @@ class TestViews(ViewCase):
|
||||
'arch': arch % ('', '<field name="noupdate"/><field name="fake_field"/>'),
|
||||
})
|
||||
|
||||
def test_for_in_label(self):
|
||||
self.assertValid('<form><field name="model"/><label for="model"/></form>')
|
||||
self.assertInvalid(
|
||||
'<form><field name="model"/><label/></form>',
|
||||
"""Label tag must contain a "for". To match label style without corresponding field or button, use 'class="o_form_label"'""",
|
||||
)
|
||||
self.assertInvalid(
|
||||
'<form><label for="model"/></form>',
|
||||
"Name 'model' used in 'label for' must be present in view but is missing.",
|
||||
)
|
||||
|
||||
def test_col_colspan_numerical(self):
|
||||
self.assertValid('<form><group col="5"></group></form>')
|
||||
self.assertInvalid(
|
||||
'<form><group col="alpha"></group></form>',
|
||||
"'col' value must be an integer (alpha)",
|
||||
)
|
||||
self.assertValid('<form><div colspan="5"></div></form>')
|
||||
self.assertInvalid(
|
||||
'<form><div colspan="alpha"></div></form>',
|
||||
"'colspan' value must be an integer (alpha)",
|
||||
)
|
||||
|
||||
def test_valid_alerts(self):
|
||||
self.assertValid('<form><div class="alert alert-success" role="alert"/></form>')
|
||||
self.assertValid('<form><div class="alert alert-success" role="alertdialog"/></form>')
|
||||
self.assertValid('<form><div class="alert alert-success" role="status"/></form>')
|
||||
self.assertWarning('<form><div class="alert alert-success"/></form>')
|
||||
|
||||
def test_valid_prohibited_none_role(self):
|
||||
self.assertWarning('<form><div role="none"/></form>')
|
||||
self.assertWarning('<form><div role="presentation"/></form>')
|
||||
|
||||
def test_valid_alternative_image_text(self):
|
||||
self.assertValid('<form><img src="a" alt="a image"></img></form>')
|
||||
self.assertWarning('<form><img src="a"></img></form>')
|
||||
|
||||
def test_valid_accessibility_icon_text(self):
|
||||
self.assertWarning(
|
||||
'<form><span class="fa fa-warning"/></form>',
|
||||
'A <span> with fa class (fa fa-warning) must have title in its tag, parents, descendants or have text'
|
||||
)
|
||||
self.assertWarning(
|
||||
'<form><button icon="fa-warning"/></form>',
|
||||
'A button with icon attribute (fa-warning) must have title in its tag, parents, descendants or have text'
|
||||
)
|
||||
self.assertValid('<form><button icon="fa-warning"/>text</form>')
|
||||
self.assertValid('<form><span class="fa fa-warning"/>text</form>')
|
||||
self.assertValid('<form>text<span class="fa fa-warning"/></form>')
|
||||
self.assertValid('<form><span class="fa fa-warning">text</span></form>')
|
||||
self.assertValid('<form><span title="text" class="fa fa-warning"/></form>')
|
||||
self.assertValid('<form><span aria-label="text" class="fa fa-warning"/></form>')
|
||||
|
||||
def test_valid_simili_button(self):
|
||||
self.assertWarning('<form><a class="btn"/></form>')
|
||||
self.assertValid('<form><a class="btn" role="button"/></form>')
|
||||
|
||||
def test_valid_dialog(self):
|
||||
self.assertWarning('<form><div class="modal"/></form>')
|
||||
self.assertValid('<form><div role="dialog" class="modal"></div></form>')
|
||||
self.assertWarning('<form><div class="modal-header"/></form>')
|
||||
self.assertValid('<form><header class="modal-header"/></form>')
|
||||
self.assertWarning('<form><div class="modal-footer"/></form>')
|
||||
self.assertValid('<form><footer class="modal-footer"/></form>')
|
||||
self.assertWarning('<form><div class="modal-body"/></form>')
|
||||
self.assertValid('<form><main class="modal-body"/></form>')
|
||||
|
||||
def test_valid_simili_dropdown(self):
|
||||
self.assertValid('<form><ul class="dropdown-menu" role="menu"></ul></form>')
|
||||
self.assertWarning('<form><ul class="dropdown-menu"></ul></form>')
|
||||
|
||||
def test_valid_simili_progressbar(self):
|
||||
self.assertValid('<form><div class="o_progressbar" role="progressbar" aria-valuenow="14" aria-valuemin="0" aria-valuemax="100">14%</div></form>')
|
||||
self.assertWarning('<form><div class="o_progressbar" aria-valuenow="14" aria-valuemin="0" aria-valuemax="100">14%</div></form>')
|
||||
self.assertWarning('<form><div class="o_progressbar" role="progressbar" aria-valuemin="0" aria-valuemax="100">14%</div></form>')
|
||||
self.assertWarning('<form><div class="o_progressbar" role="progressbar" aria-valuenow="14" aria-valuemax="100">14%</div></form>')
|
||||
self.assertWarning('<form><div class="o_progressbar" role="progressbar" aria-valuenow="14" aria-valuemin="0" >14%</div></form>')
|
||||
|
||||
def test_valid_simili_tabpanel(self):
|
||||
self.assertValid('<form><div class="tab-pane" role="tabpanel"/></form>')
|
||||
self.assertWarning('<form><div class="tab-pane"/></form>')
|
||||
|
||||
def test_valid_simili_tablist(self):
|
||||
self.assertValid('<form><div class="nav-tabs" role="tablist"/></form>')
|
||||
self.assertWarning('<form><div class="nav-tabs"/></form>')
|
||||
|
||||
def test_valid_simili_tab(self):
|
||||
self.assertValid('<form><a data-toggle="tab" role="tab" aria-controls="test"/></form>')
|
||||
self.assertWarning('<form><a data-toggle="tab" aria-controls="test"/></form>')
|
||||
self.assertWarning('<form><a data-toggle="tab" role="tab"/></form>')
|
||||
self.assertWarning('<form><a data-toggle="tab" role="tab" aria-controls="#test"/></form>')
|
||||
|
||||
def test_valid_focusable_button(self):
|
||||
self.assertValid('<form><a class="btn" role="button"/></form>')
|
||||
self.assertValid('<form><button class="btn" role="button"/></form>')
|
||||
self.assertValid('<form><select class="btn" role="button"/></form>')
|
||||
self.assertValid('<form><input type="button" class="btn" role="button"/></form>')
|
||||
self.assertValid('<form><input type="submit" class="btn" role="button"/></form>')
|
||||
self.assertValid('<form><input type="reset" class="btn" role="button"/></form>')
|
||||
self.assertValid('<form><div type="reset" class="btn btn-group" role="button"/></form>')
|
||||
self.assertValid('<form><div type="reset" class="btn btn-toolbar" role="button"/></form>')
|
||||
self.assertValid('<form><div type="reset" class="btn btn-ship" role="button"/></form>')
|
||||
self.assertWarning('<form><div class="btn" role="button"/></form>')
|
||||
self.assertWarning('<form><input type="email" class="btn" role="button"/></form>')
|
||||
|
||||
def test_address_view(self):
|
||||
# pe_partner_address_form
|
||||
address_arch = """<form><div class="o_address_format"><field name="parent_name"/></div></form>"""
|
||||
address_view = self.View.create({
|
||||
'name': 'view',
|
||||
'model': 'res.partner',
|
||||
'arch': address_arch,
|
||||
'priority': 900,
|
||||
})
|
||||
|
||||
# view can be created without address_view
|
||||
form_arch = """<form><field name="id"/><div class="o_address_format"><field name="street"/></div></form>"""
|
||||
partner_view = self.View.create({
|
||||
'name': 'view',
|
||||
'model': 'res.partner',
|
||||
'arch': form_arch,
|
||||
})
|
||||
|
||||
# default view, no address_view defined
|
||||
arch = self.env['res.partner'].fields_view_get(view_id=partner_view.id)['arch']
|
||||
self.assertIn('"street"', arch)
|
||||
self.assertNotIn('"parent_name"', arch)
|
||||
|
||||
# custom view, address_view defined
|
||||
self.env.company.country_id.address_view_id = address_view
|
||||
arch = self.env['res.partner'].fields_view_get(view_id=partner_view.id)['arch']
|
||||
self.assertNotIn('"street"', arch)
|
||||
self.assertIn('"parent_name"', arch)
|
||||
# weird result: <form> inside a <form>
|
||||
self.assertRegex(arch, r"<form>.*<form>.*</form>.*</form>")
|
||||
|
||||
def test_graph_fields(self):
|
||||
self.assertValid('<graph string="Graph"><field name="model" type="row"/><field name="inherit_id" type="measure"/></graph>')
|
||||
self.assertInvalid(
|
||||
'<graph string="Graph"><label for="model"/><field name="model" type="row"/><field name="inherit_id" type="measure"/></graph>',
|
||||
'A <graph> can only contains <field> nodes, found a <label>'
|
||||
)
|
||||
|
||||
def assertValid(self, arch, name='valid view'):
|
||||
self.View.create({
|
||||
'name': name,
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch,
|
||||
})
|
||||
|
||||
def assertInvalid(self, arch, expected_message=None, name='invalid view'):
|
||||
with self.assertRaises(ValidationError) as catcher, mute_logger('odoo.addons.base.models.ir_ui_view'):
|
||||
self.View.create({
|
||||
'name': name,
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch,
|
||||
})
|
||||
message = str(catcher.exception.args[0])
|
||||
self.assertIn('Error context:\nView `%s`' % name, message)
|
||||
if expected_message:
|
||||
self.assertIn(expected_message, message)
|
||||
else:
|
||||
_logger.warning(message)
|
||||
|
||||
def assertWarning(self, arch, expected_message=None, name='invalid view'):
|
||||
with self.assertLogs('odoo.addons.base.models.ir_ui_view', level="WARNING") as log_catcher:
|
||||
self.View.create({
|
||||
'name': name,
|
||||
'model': 'ir.ui.view',
|
||||
'arch': arch,
|
||||
})
|
||||
self.assertEqual(len(log_catcher.output), 1, "Exactly one warning should be logged")
|
||||
message = log_catcher.output[0]
|
||||
self.assertIn('Error context:\nView `%s`' % name, message)
|
||||
if expected_message:
|
||||
self.assertIn(expected_message, message)
|
||||
|
||||
|
||||
class ViewModeField(ViewCase):
|
||||
"""
|
||||
@@ -2293,6 +2701,43 @@ class TestQWebRender(ViewCase):
|
||||
self.assertNotEqual(content1, content3)
|
||||
|
||||
|
||||
class TestValidationTools(common.BaseCase):
|
||||
|
||||
def test_get_domain_idents(self):
|
||||
res = view_validation.get_domain_identifiers("['|', ('model', '=', parent.model or need_model), ('need_model', '=', False)]")
|
||||
self.assertEqual(res, ({'model', 'need_model'}, {'parent.model', 'need_model'}))
|
||||
|
||||
def test_process_2_level_parents(self):
|
||||
res = view_validation.get_domain_identifiers("['|', ('model', '=', parent.parent.model)]")
|
||||
self.assertEqual(res, ({'model'}, {'parent.parent.model'}))
|
||||
|
||||
def test_get_dict_asts(self):
|
||||
res = view_validation.get_dict_asts("{'test': False, 'required': [('model', '!=', False)], 'invisible': ['|', ('model', '=', parent.model or need_model), ('need_model', '=', False)]}")
|
||||
self.assertEqual(set(res.keys()), set(['test', 'required', 'invisible']))
|
||||
self.assertIsInstance(res['test'], ast.NameConstant)
|
||||
self.assertIsInstance(res['required'], ast.List)
|
||||
self.assertIsInstance(res['invisible'], ast.List)
|
||||
self.assertEqual(view_validation.get_domain_identifiers(res['invisible']), ({'model', 'need_model'}, {'parent.model', 'need_model'}))
|
||||
|
||||
def test_get_expression_identities(self):
|
||||
self.assertEqual(
|
||||
view_validation.get_variable_names("context_today().strftime('%Y-%m-%d')"),
|
||||
set(),
|
||||
)
|
||||
self.assertEqual(
|
||||
view_validation.get_variable_names("field and field[0] or not field2"),
|
||||
{'field', 'field2'},
|
||||
)
|
||||
self.assertEqual(
|
||||
view_validation.get_variable_names("context_today().strftime('%Y-%m-%d') or field"),
|
||||
{'field'},
|
||||
)
|
||||
self.assertEqual(
|
||||
view_validation.get_variable_names("(datetime.datetime.combine(context_today(), datetime.time(x,y,z)).to_utc()).strftime('%Y-%m-%d %H:%M:%S')"),
|
||||
{'x', 'y', 'z'},
|
||||
)
|
||||
|
||||
|
||||
@common.tagged('post_install', '-at_install', '-standard', 'migration')
|
||||
class TestAllViews(common.TransactionCase):
|
||||
def test_views(self):
|
||||
|
||||
@@ -731,6 +731,12 @@ class Field(MetaField('DummyField', (object,), {})):
|
||||
return field_help.get(self.name) or self.help
|
||||
return self.help
|
||||
|
||||
def is_editable(self):
|
||||
""" Return whether the field can be editable in a view. """
|
||||
return not self.readonly or self.states and any(
|
||||
'readonly' in item for items in self.states.values() for item in items
|
||||
)
|
||||
|
||||
############################################################################
|
||||
#
|
||||
# Conversion of values
|
||||
|
||||
+3
-3
@@ -1495,17 +1495,17 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
|
||||
* if some tag other than 'position' is found in parent view
|
||||
:raise Invalid ArchitectureError: if there is view type other than form, tree, calendar, search etc defined on the structure
|
||||
"""
|
||||
View = self.env['ir.ui.view']
|
||||
view = self.env['ir.ui.view'].browse(view_id)
|
||||
|
||||
# Get the view arch and all other attributes describing the composition of the view
|
||||
result = self._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
|
||||
|
||||
# Override context for postprocessing
|
||||
if view_id and result.get('base_model', self._name) != self._name:
|
||||
View = View.with_context(base_model_name=result['base_model'])
|
||||
view = view.with_context(base_model_name=result['base_model'])
|
||||
|
||||
# Apply post processing, groups and modifiers etc...
|
||||
xarch, xfields = View.postprocess_and_fields(self._name, etree.fromstring(result['arch']), view_id)
|
||||
xarch, xfields = view.postprocess_and_fields(etree.fromstring(result['arch']), model=self._name)
|
||||
result['arch'] = xarch
|
||||
result['fields'] = xfields
|
||||
|
||||
|
||||
+71
-357
@@ -6,10 +6,8 @@ import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from functools import partial
|
||||
from lxml import etree
|
||||
from odoo import tools
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,20 +15,6 @@ _logger = logging.getLogger(__name__)
|
||||
_validators = collections.defaultdict(list)
|
||||
_relaxng_cache = {}
|
||||
|
||||
# attributes in views that may contain references to field names
|
||||
ATTRS_WITH_FIELD_NAMES = {
|
||||
'context',
|
||||
'domain',
|
||||
'decoration-bf',
|
||||
'decoration-it',
|
||||
'decoration-danger',
|
||||
'decoration-info',
|
||||
'decoration-muted',
|
||||
'decoration-primary',
|
||||
'decoration-success',
|
||||
'decoration-warning',
|
||||
}
|
||||
|
||||
READONLY = re.compile(r"\breadonly\b")
|
||||
|
||||
|
||||
@@ -39,7 +23,6 @@ def _get_attrs_symbols():
|
||||
return {
|
||||
'True', 'False', 'None', # those are identifiers in Python 2.7
|
||||
'self',
|
||||
'parent',
|
||||
'id',
|
||||
'uid',
|
||||
'context',
|
||||
@@ -62,90 +45,88 @@ def _get_attrs_symbols():
|
||||
}
|
||||
|
||||
|
||||
def _view_is_editable(node):
|
||||
""" Return whether the node is an editable view. """
|
||||
return node.tag == 'form' or node.tag == 'tree' and node.get('editable')
|
||||
|
||||
|
||||
def field_is_editable(field, node):
|
||||
""" Return whether a field is editable (not always readonly). """
|
||||
return (
|
||||
(not field.readonly or READONLY.search(str(field.states or ""))) and
|
||||
(node.get('readonly') != "1" or READONLY.search(node.get('attrs') or ""))
|
||||
)
|
||||
|
||||
|
||||
def get_attrs_field_names(env, arch, model, editable):
|
||||
""" Retrieve the field names appearing in context, domain and attrs, and
|
||||
return a list of triples ``(field_name, attr_name, attr_value)``.
|
||||
def get_variable_names(expr):
|
||||
""" Return the subexpressions of the kind "VARNAME(.ATTNAME)*" in the given
|
||||
string or AST node.
|
||||
"""
|
||||
VIEW_TYPES = {item[0] for item in type(env['ir.ui.view']).type.selection}
|
||||
symbols = _get_attrs_symbols() | {None}
|
||||
result = []
|
||||
IGNORED = _get_attrs_symbols()
|
||||
names = set()
|
||||
|
||||
def get_name(node):
|
||||
""" return the name from an AST node, or None """
|
||||
def get_name_seq(node):
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
return [node.id]
|
||||
elif isinstance(node, ast.Attribute):
|
||||
left = get_name_seq(node.value)
|
||||
return left and left + [node.attr]
|
||||
|
||||
def get_subname(get, node):
|
||||
""" return the subfield name from an AST node, or None """
|
||||
if isinstance(node, ast.Attribute) and get(node.value) == 'parent':
|
||||
return node.attr
|
||||
def process(node):
|
||||
seq = get_name_seq(node)
|
||||
if seq and seq[0] not in IGNORED:
|
||||
names.add('.'.join(seq))
|
||||
else:
|
||||
for child in ast.iter_child_nodes(node):
|
||||
process(child)
|
||||
|
||||
def process_expr(expr, get, key, val):
|
||||
""" parse `expr` and collect triples """
|
||||
for node in ast.walk(ast.parse(expr.strip(), mode='eval')):
|
||||
name = get(node)
|
||||
if name not in symbols:
|
||||
result.append((name, key, val))
|
||||
if isinstance(expr, str):
|
||||
expr = ast.parse(expr.strip(), mode='eval').body
|
||||
process(expr)
|
||||
|
||||
def process_attrs(expr, get, key, val):
|
||||
""" parse `expr` and collect field names in lhs of conditions. """
|
||||
for domain in safe_eval(expr).values():
|
||||
if not isinstance(domain, list):
|
||||
continue
|
||||
for arg in domain:
|
||||
if isinstance(arg, (tuple, list)):
|
||||
process_expr(str(arg[0]), get, key, expr)
|
||||
return names
|
||||
|
||||
def process(node, model, editable, get=get_name):
|
||||
""" traverse `node` and collect triples """
|
||||
if node.tag in VIEW_TYPES:
|
||||
# determine whether this view is editable
|
||||
editable = editable and _view_is_editable(node)
|
||||
elif node.tag in ('field', 'groupby'):
|
||||
# determine whether the field is editable
|
||||
field = model._fields.get(node.get('name'))
|
||||
if field:
|
||||
editable = editable and field_is_editable(field, node)
|
||||
|
||||
for key, val in node.items():
|
||||
if not val:
|
||||
continue
|
||||
if key in ATTRS_WITH_FIELD_NAMES:
|
||||
process_expr(val, get, key, val)
|
||||
elif key == 'attrs':
|
||||
process_attrs(val, get, key, val)
|
||||
def get_dict_asts(expr):
|
||||
""" Check that the given string or AST node represents a dict expression
|
||||
where all keys are string literals, and return it as a dict mapping string
|
||||
keys to the AST of values.
|
||||
"""
|
||||
if isinstance(expr, str):
|
||||
expr = ast.parse(expr.strip(), mode='eval').body
|
||||
|
||||
if node.tag in ('field', 'groupby') and field and field.relational:
|
||||
if editable and not node.get('domain'):
|
||||
domain = field._description_domain(env)
|
||||
# process the field's domain as if it was in the view
|
||||
if isinstance(domain, str):
|
||||
process_expr(domain, get, 'domain', domain)
|
||||
# retrieve subfields of 'parent'
|
||||
model = env[field.comodel_name]
|
||||
get = partial(get_subname, get)
|
||||
if not isinstance(expr, ast.Dict):
|
||||
raise ValueError("Non-dict expression")
|
||||
if not all(isinstance(key, ast.Str) for key in expr.keys):
|
||||
raise ValueError("Non-string literal dict key")
|
||||
return {key.s: val for key, val in zip(expr.keys, expr.values)}
|
||||
|
||||
for child in node:
|
||||
if node.tag == 'search' and child.tag == 'searchpanel':
|
||||
# searchpanel part has to be validated independently
|
||||
continue
|
||||
process(child, model, editable, get)
|
||||
|
||||
process(arch, model, editable)
|
||||
return result
|
||||
def get_domain_identifiers(expr):
|
||||
""" Check that the given string or AST node represents a domain expression,
|
||||
and return a pair of sets ``(fields, vars)`` where ``fields`` are the field
|
||||
names on the left-hand side of conditions, and ``vars`` are the variable
|
||||
names on the right-hand side of conditions.
|
||||
"""
|
||||
if not expr: # case of expr=""
|
||||
return (set(), set())
|
||||
if isinstance(expr, str):
|
||||
expr = ast.parse(expr.strip(), mode='eval').body
|
||||
|
||||
def check(condition):
|
||||
if not condition:
|
||||
raise ValueError("Expression is not a valid domain")
|
||||
|
||||
check(isinstance(expr, ast.List))
|
||||
fnames = set()
|
||||
vnames = set()
|
||||
leaves = list(ast.iter_child_nodes(expr))
|
||||
check(isinstance(leaves.pop(), ast.Load))
|
||||
for leaf in leaves:
|
||||
if isinstance(leaf, ast.Str):
|
||||
# note: this doesn't check the and/or structure
|
||||
check(leaf.s in ('&', '|', '!'))
|
||||
else:
|
||||
check(isinstance(leaf, (ast.List, ast.Tuple)))
|
||||
tuple_ = list(ast.iter_child_nodes(leaf))
|
||||
check(isinstance(tuple_.pop(), ast.Load) and len(tuple_) == 3)
|
||||
lhs, operator, rhs = tuple_
|
||||
if isinstance(lhs, ast.Str):
|
||||
fnames.add(lhs.s)
|
||||
else:
|
||||
# limitation: we do not list fnames in this case
|
||||
vnames.update(get_variable_names(lhs))
|
||||
check(isinstance(operator, ast.Str))
|
||||
vnames.update(get_variable_names(rhs))
|
||||
|
||||
return (fnames, vnames)
|
||||
|
||||
|
||||
def valid_view(arch, **kwargs):
|
||||
@@ -194,270 +175,3 @@ def schema_valid(arch, **kwargs):
|
||||
result = False
|
||||
return result
|
||||
return True
|
||||
|
||||
|
||||
@validate('search')
|
||||
def valid_searchpanel(arch, **kwargs):
|
||||
""" There must be at most one ``searchpanel`` node in search view archs. """
|
||||
return len(arch.xpath('/search/searchpanel')) <= 1
|
||||
|
||||
|
||||
@validate('search')
|
||||
def valid_searchpanel_domain_select(arch, **kwargs):
|
||||
""" In the searchpanel, the attribute ``domain`` can only be used on ``field`` nodes with
|
||||
``select`` attribute set to ``multi``. """
|
||||
for child in arch.xpath('/search/searchpanel/field'):
|
||||
if child.get('domain') and child.get('select') != 'multi':
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@validate('search')
|
||||
def valid_searchpanel_domain_fields(arch, **kwargs):
|
||||
""" In the searchpanel, fields used in the ``domain`` attribute must be present inside the
|
||||
``searchpanel`` node with ``select`` attribute not set to ``multi``. """
|
||||
searchpanel = arch.xpath('/search/searchpanel')
|
||||
if searchpanel:
|
||||
env = kwargs['env']
|
||||
model = kwargs['model']
|
||||
attrs_fields = [r[0] for r in get_attrs_field_names(env, searchpanel[0], env[model], False)]
|
||||
non_multi_fields = [
|
||||
c.get('name') for c in arch.xpath('/search/searchpanel/field')
|
||||
if c.get('select') != 'multi'
|
||||
]
|
||||
return len(set(attrs_fields) - set(non_multi_fields)) == 0
|
||||
return True
|
||||
|
||||
|
||||
@validate('form')
|
||||
def valid_page_in_book(arch, **kwargs):
|
||||
"""A `page` node must be below a `notebook` node."""
|
||||
return not arch.xpath('//page[not(ancestor::notebook)]')
|
||||
|
||||
|
||||
@validate('graph')
|
||||
def valid_field_in_graph(arch, **kwargs):
|
||||
""" Children of ``graph`` can only be ``field`` """
|
||||
return all(
|
||||
child.tag == 'field'
|
||||
for child in arch.xpath('/graph/*')
|
||||
)
|
||||
|
||||
|
||||
@validate('tree')
|
||||
def valid_field_in_tree(arch, **kwargs):
|
||||
""" Children of ``tree`` view must be ``field`` or ``button`` or ``control`` or ``groupby``."""
|
||||
return all(
|
||||
child.tag in ('field', 'button', 'control', 'groupby')
|
||||
for child in arch.xpath('/tree/*')
|
||||
)
|
||||
|
||||
|
||||
@validate('form', 'graph', 'tree', 'activity')
|
||||
def valid_att_in_field(arch, **kwargs):
|
||||
""" ``field`` nodes must all have a ``@name`` """
|
||||
return not arch.xpath('//field[not(@name)]')
|
||||
|
||||
|
||||
@validate('form')
|
||||
def valid_att_in_label(arch, **kwargs):
|
||||
""" ``label`` nodes must have a ``@for`` """
|
||||
return not arch.xpath('//label[not(@for) and not(descendant::input)]')
|
||||
|
||||
|
||||
@validate('form')
|
||||
def valid_att_in_form(arch, **kwargs):
|
||||
return True
|
||||
|
||||
|
||||
@validate('form')
|
||||
def valid_type_in_colspan(arch, **kwargs):
|
||||
"""A `colspan` attribute must be an `integer` type."""
|
||||
return all(
|
||||
attrib.isdigit()
|
||||
for attrib in arch.xpath('//@colspan')
|
||||
)
|
||||
|
||||
|
||||
@validate('form')
|
||||
def valid_type_in_col(arch, **kwargs):
|
||||
"""A `col` attribute must be an `integer` type."""
|
||||
return all(
|
||||
attrib.isdigit()
|
||||
for attrib in arch.xpath('//@col')
|
||||
)
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_alternative_image_text(arch, **kwargs):
|
||||
"""An `img` tag must have an alt value."""
|
||||
if arch.xpath('//img[not(@alt or @t-att-alt or @t-attf-alt)]'):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_simili_button(arch, **kwargs):
|
||||
"""A simili button must be tagged with "role='button'"."""
|
||||
# Select elements with class 'btn'
|
||||
xpath = '//a[contains(concat(" ", @class), " btn")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class), " btn")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class), " btn")]'
|
||||
xpath += '[not(@role="button")]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_simili_dropdown(arch, **kwargs):
|
||||
"""A simili dropdown must be tagged with "role='menu'"."""
|
||||
xpath = '//*[contains(concat(" ", @class, " "), " dropdown-menu ")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class, " "), " dropdown-menu ")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class, " "), " dropdown-menu ")]'
|
||||
xpath += '[not(@role="menu")]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_simili_progressbar(arch, **kwargs):
|
||||
"""A simili progressbar must be tagged with "role='progressbar'" and have
|
||||
aria-valuenow, aria-valuemin and aria-valuemax attributes."""
|
||||
# Select elements with class 'btn'
|
||||
xpath = '//*[contains(concat(" ", @class, " "), " o_progressbar ")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class, " "), " o_progressbar ")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class, " "), " o_progressbar ")]'
|
||||
xpath += '[not(self::progress)]'
|
||||
xpath += '[not(@role="progressbar")]'
|
||||
xpath += '[not(@aria-valuenow or @t-att-aria-valuenow or @t-attf-aria-valuenow)]'
|
||||
xpath += '[not(@aria-valuemin or @t-att-aria-valuemin or @t-attf-aria-valuemin)]'
|
||||
xpath += '[not(@aria-valuemax or @t-att-aria-valuemax or @t-attf-aria-valuemax)]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_dialog(arch, **kwargs):
|
||||
"""A dialog must use role="dialog" and its header, body and footer contents must use <header/>, <main/> and <footer/>."""
|
||||
# Select elements with class 'btn'
|
||||
xpath = '//*[contains(concat(" ", @class, " "), " modal ")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class, " "), " modal ")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal ")]'
|
||||
xpath += '[not(@role="dialog")]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
|
||||
xpath = '//*[contains(concat(" ", @class, " "), " modal-header ")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-header ")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-header ")]'
|
||||
xpath += '[not(self::header)]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
|
||||
xpath = '//*[contains(concat(" ", @class, " "), " modal-body ")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-body ")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-body ")]'
|
||||
xpath += '[not(self::main)]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
|
||||
xpath = '//*[contains(concat(" ", @class, " "), " modal-footer ")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-footer ")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-footer ")]'
|
||||
xpath += '[not(self::footer)]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_simili_tabpanel(arch, **kwargs):
|
||||
"""A tab panel with tab-pane class must have role="tabpanel"."""
|
||||
# Select elements with class 'btn'
|
||||
xpath = '//*[contains(concat(" ", @class, " "), " tab-pane ")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class, " "), " tab-pane ")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class, " "), " tab-pane ")]'
|
||||
xpath += '[not(@role="tabpanel")]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_simili_tab(arch, **kwargs):
|
||||
"""A tab link must have role="tab", a link to an id (without #) by aria-controls."""
|
||||
# Select elements with class 'btn'
|
||||
xpath = '//*[@data-toggle="tab"]'
|
||||
xpath += '[not(@role="tab")'
|
||||
xpath += 'or not(@aria-controls or @t-att-aria-controls or @t-attf-aria-controls)'
|
||||
xpath += 'or contains(@aria-controls, "#") or contains(@t-att-aria-controls, "#")]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_simili_tablist(arch, **kwargs):
|
||||
"""A tab list with class nav-tabs must have role="tablist"."""
|
||||
# Select elements with class 'btn'
|
||||
xpath = '//*[contains(concat(" ", @class, " "), " nav-tabs ")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class, " "), " nav-tabs ")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class, " "), " nav-tabs ")]'
|
||||
xpath += '[not(@role="tablist")]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_focusable_button(arch, **kwargs):
|
||||
"""A simili button must be with a `button`, an `input` (with type `button`, `submit` or `reset`) or a `a` tag."""
|
||||
xpath = '//*[contains(concat(" ", @class), " btn")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class), " btn")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class), " btn")]'
|
||||
xpath += '[not(self::a)]'
|
||||
xpath += '[not(self::button)]'
|
||||
xpath += '[not(self::select)]'
|
||||
xpath += '[not(self::input[@type="button"])]'
|
||||
xpath += '[not(self::input[@type="submit"])]'
|
||||
xpath += '[not(self::input[@type="reset"])]'
|
||||
xpath += '[not(contains(@class, "btn-group"))]'
|
||||
xpath += '[not(contains(@t-att-class, "btn-group"))]'
|
||||
xpath += '[not(contains(@t-attf-class, "btn-group"))]'
|
||||
xpath += '[not(contains(@class, "btn-toolbar"))]'
|
||||
xpath += '[not(contains(@t-att-class, "btn-toolbar"))]'
|
||||
xpath += '[not(contains(@t-attf-class, "btn-toolbar"))]'
|
||||
xpath += '[not(contains(@class, "btn-ship"))]'
|
||||
xpath += '[not(contains(@t-att-class, "btn-ship"))]'
|
||||
xpath += '[not(contains(@t-attf-class, "btn-ship"))]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_prohibited_none_role(arch, **kwargs):
|
||||
"""A role can't be `none` or `presentation`. All your elements must be accessible with screen readers, describe it."""
|
||||
xpath = '//*[@role="none" or @role="presentation"]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
|
||||
def valid_alerts(arch, **kwargs):
|
||||
"""An alert (class alert-*) must have an alert, alertdialog or status role. Please use alert and alertdialog only for what expects to stop any activity to be read immediatly."""
|
||||
xpath = '//*[contains(concat(" ", @class), " alert-")'
|
||||
xpath += ' or contains(concat(" ", @t-att-class), " alert-")'
|
||||
xpath += ' or contains(concat(" ", @t-attf-class), " alert-")]'
|
||||
xpath += '[not(contains(@class, "alert-link") or contains(@t-att-class, "alert-link")'
|
||||
xpath += ' or contains(@t-attf-class, "alert-link"))]'
|
||||
xpath += '[not(@role="alert")]'
|
||||
xpath += '[not(@role="alertdialog")]'
|
||||
xpath += '[not(@role="status")]'
|
||||
if arch.xpath(xpath):
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user