" tag with "btn" class must have "button" role'
self._log_view_warning(msg, node)
def _validate_tag_ul(self, node, name_manager, node_info):
if node_info['validate']:
# was applied to all nodes, but in practice only used on div and ul
self._check_dropdown_menu(node)
def _validate_tag_div(self, node, name_manager, node_info):
if node_info['validate']:
self._check_dropdown_menu(node)
self._check_progress_bar(node)
#------------------------------------------------------
# Validation tools
#------------------------------------------------------
def _check_dropdown_menu(self, node):
#('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
if any('dropdown-menu' in node.get(cl, '') for cl in att_names('class')):
if node.get('role') != 'menu':
msg = 'dropdown-menu class must have menu role'
self._log_view_warning(msg, node)
def _check_progress_bar(self, node):
if any('o_progressbar' in node.get(cl, '') for cl in att_names('class')):
if node.get('role') != 'progressbar':
msg = 'o_progressbar class must have progressbar role'
self._log_view_warning(msg, node)
if not any(node.get(at) for at in att_names('aria-valuenow')):
msg = 'o_progressbar class must have aria-valuenow attribute'
self._log_view_warning(msg, node)
if not any(node.get(at) for at in att_names('aria-valuemin')):
msg = 'o_progressbar class must have aria-valuemin attribute'
self._log_view_warning(msg, node)
if not any(node.get(at) for at in att_names('aria-valuemax')):
msg = 'o_progressbar class must have aria-valuemaxattribute'
self._log_view_warning(msg, node)
def _validate_attrs(self, node, name_manager, node_info):
""" Generic validation of node attrs. """
for attr, expr in node.items():
if attr in ('class', 't-att-class', 't-attf-class'):
self._validate_classes(node, expr)
elif attr == 'attrs':
for key, val_ast in get_dict_asts(expr).items():
if isinstance(val_ast, ast.List):
# domains in attrs are used for readonly, invisible, ...
# and thus are only executed client side
fnames, vnames = self._get_domain_identifiers(node, val_ast, attr, expr)
name_manager.must_have_fields(fnames | vnames, f"attrs ({expr})")
else:
vnames = get_variable_names(val_ast)
if vnames:
name_manager.must_have_fields(vnames, f"attrs ({expr})")
elif attr == 'context':
for key, val_ast in get_dict_asts(expr).items():
if key == 'group_by': # only in context
if not isinstance(val_ast, ast.Str):
msg = _(
'"group_by" value must be a string %(attribute)s=%(value)r',
attribute=attr, value=expr,
)
self._raise_view_error(msg, node)
group_by = val_ast.s
fname = group_by.split(':')[0]
if fname not in name_manager.model._fields:
msg = _(
'Unknown field "%(field)s" in "group_by" value in %(attribute)s=%(value)r',
field=fname, attribute=attr, value=expr,
)
self._raise_view_error(msg, node)
else:
vnames = get_variable_names(val_ast)
if vnames:
name_manager.must_have_fields(vnames, f"context ({expr})")
elif attr == 'groups':
for group in expr.replace('!', '').split(','):
# further improvement: add all groups to name_manager in
# order to batch check them at the end
if not self.env['ir.model.data']._xmlid_to_res_id(group.strip(), raise_if_not_found=False):
msg = "The group %r defined in view does not exist!"
self._log_view_warning(msg % group, node)
elif attr in ('col', 'colspan'):
# col check is mainly there for the tag 'group', but previous
# check was generic in view form
if not expr.isdigit():
self._raise_view_error(
_('%(attribute)r value must be an integer (%(value)s)',
attribute=attr, value=expr),
node,
)
elif attr.startswith('decoration-'):
vnames = get_variable_names(expr)
if vnames:
name_manager.must_have_fields(vnames, f"{attr}={expr}")
elif attr == 'data-toggle' and expr == 'tab':
if node.get('role') != 'tab':
msg = 'tab link (data-toggle="tab") must have "tab" role'
self._log_view_warning(msg, node)
aria_control = node.get('aria-controls') or node.get('t-att-aria-controls')
if not aria_control and not node.get('t-attf-aria-controls'):
msg = 'tab link (data-toggle="tab") must have "aria_control" defined'
self._log_view_warning(msg, node)
if aria_control and '#' in aria_control:
msg = 'aria-controls in tablink cannot contains "#"'
self._log_view_warning(msg, node)
elif attr == "role" and expr in ('presentation', 'none'):
msg = ("A role cannot be `none` or `presentation`. "
"All your elements must be accessible with screen readers, describe it.")
self._log_view_warning(msg, node)
elif attr == 'group':
msg = "attribute 'group' is not valid. Did you mean 'groups'?"
self._log_view_warning(msg, node)
def _validate_classes(self, node, expr):
""" Validate the classes present on node. """
classes = set(expr.split(' '))
# Be careful: not always true if it is an expression
# example:
if 'modal' in classes and node.get('role') != 'dialog':
msg = '"modal" class should only be used with "dialog" role'
self._log_view_warning(msg, node)
if 'modal-header' in classes and node.tag != 'header':
msg = '"modal-header" class should only be used in "header" tag'
self._log_view_warning(msg, node)
if 'modal-body' in classes and node.tag != 'main':
msg = '"modal-body" class should only be used in "main" tag'
self._log_view_warning(msg, node)
if 'modal-footer' in classes and node.tag != 'footer':
msg = '"modal-footer" class should only be used in "footer" tag'
self._log_view_warning(msg, node)
if 'tab-pane' in classes and node.get('role') != 'tabpanel':
msg = '"tab-pane" class should only be used with "tabpanel" role'
self._log_view_warning(msg, node)
if 'nav-tabs' in classes and node.get('role') != 'tablist':
msg = 'A tab list with class nav-tabs must have role="tablist"'
self._log_view_warning(msg, node)
if any(klass.startswith('alert-') for klass in classes):
if (
node.get('role') not in ('alert', 'alertdialog', 'status')
and 'alert-link' not in classes
):
msg = ("An alert (class alert-*) must have an alert, alertdialog or "
"status role or an alert-link class. Please use alert and "
"alertdialog only for what expects to stop any activity to "
"be read immediately.")
self._log_view_warning(msg, node)
if any(klass.startswith('fa-') for klass in classes):
description = 'A <%s> with fa class (%s)' % (node.tag, expr)
self._validate_fa_class_accessibility(node, description)
if any(klass.startswith('btn') for klass in classes):
if node.tag in ('a', 'button', 'select'):
pass
elif node.tag == 'input' and node.get('type') in ('button', 'submit', 'reset'):
pass
elif any(klass in classes for klass in ('btn-group', 'btn-toolbar', 'btn-ship')):
pass
else:
msg = ("A simili button must be in tag a/button/select or tag `input` "
"with type button/submit/reset or have class in "
"btn-group/btn-toolbar/btn-ship")
self._log_view_warning(msg, node)
def _validate_fa_class_accessibility(self, node, description):
valid_aria_attrs = {
*att_names('title'), *att_names('aria-label'), *att_names('aria-labelledby'),
}
valid_t_attrs = {'t-value', 't-raw', 't-field', 't-esc', 't-out'}
## Following or preceding text
if (node.tail or '').strip() or (node.getparent().text or '').strip():
# text or text or
return
## Following or preceding text in span
def has_text(elem):
if elem is None:
return False
if elem.tag == 'span' and elem.text:
return True
if elem.tag == 't' and (elem.get('t-esc') or elem.get('t-raw')):
return True
return False
if has_text(node.getnext()) or has_text(node.getprevious()):
return
def has_title_or_aria_label(node):
return any(node.get(attr) for attr in valid_aria_attrs)
## Aria label can be on ancestors
if any(map(has_title_or_aria_label, node.iterancestors())):
return
if node.get('string'):
return
## And we ignore all elements with describing in children
def contains_description(node, depth=0):
if depth > 2:
_logger.warning('excessive depth in fa')
if any(node.get(attr) for attr in valid_t_attrs):
return True
if has_title_or_aria_label(node):
return True
if node.tag in ('label', 'field'):
return True
if node.text: # not sure, does it match *[text()]
return True
return any(contains_description(child, depth+1) for child in node)
if contains_description(node):
return
msg = '%s must have title in its tag, parents, descendants or have text'
self._log_view_warning(msg % description, node)
def _get_domain_identifiers(self, node, domain, use, expr=None):
try:
return get_domain_identifiers(domain)
except ValueError:
msg = _("Invalid domain format %(expr)s in %(use)s", expr=expr or domain, use=use)
self._raise_view_error(msg, node)
def _check_field_paths(self, node, field_paths, model_name, use):
""" Check whether the given field paths (dot-separated field names)
correspond to actual sequences of fields on the given model.
"""
for field_path in field_paths:
names = field_path.split('.')
Model = self.pool[model_name]
for index, name in enumerate(names):
if Model is None:
msg = _(
'Non-relational field %(field)r in path %(field_path)r in %(use)s)',
field=names[index - 1], field_path=field_path, use=use,
)
self._raise_view_error(msg, node)
try:
field = Model._fields[name]
except KeyError:
msg = _(
'Unknown field "%(model)s.%(field)s" in %(use)s)',
model=Model._name, field=name, use=use,
)
self._raise_view_error(msg, node)
if not field._description_searchable:
msg = _(
'Unsearchable field %(field)r in path %(field_path)r in %(use)s)',
field=name, field_path=field_path, use=use,
)
self._raise_view_error(msg, node)
Model = self.pool.get(field.comodel_name)
#------------------------------------------------------
# QWeb template views
#------------------------------------------------------
def _read_template_keys(self):
""" Return the list of context keys to use for caching ``_read_template``. """
return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations']
# apply ormcache_context decorator unless in dev mode...
@api.model
def _read_template(self, view_id):
arch_tree = self.browse(view_id)._get_combined_arch()
self.distribute_branding(arch_tree)
return etree.tostring(arch_tree, encoding='unicode')
@api.model
def get_view_id(self, template):
""" Return the view ID corresponding to ``template``, which may be a
view ID or an XML ID. Note that this method may be overridden for other
kinds of template values.
This method could return the ID of something that is not a view (when
using fallback to `_xmlid_to_res_id`).
"""
if isinstance(template, int):
return template
if '.' not in template:
raise ValueError('Invalid template id: %r' % template)
view = self.sudo().search([('key', '=', template)], limit=1)
return view and view.id or self.env['ir.model.data']._xmlid_to_res_id(template, raise_if_not_found=True)
def clear_cache(self):
""" Deprecated, use `clear_caches` instead. """
if 'xml' not in config['dev_mode']:
self.clear_caches()
def _contains_branded(self, node):
return node.tag == 't'\
or 't-raw' in node.attrib\
or 't-call' in node.attrib\
or any(self.is_node_branded(child) for child in node.iterdescendants())
def _pop_view_branding(self, element):
distributed_branding = dict(
(attribute, element.attrib.pop(attribute))
for attribute in MOVABLE_BRANDING
if element.get(attribute))
return distributed_branding
def distribute_branding(self, e, branding=None, parent_xpath='',
index_map=ConstantMapping(1)):
if e.get('t-ignore') or e.tag == 'head':
# remove any view branding possibly injected by inheritance
attrs = set(MOVABLE_BRANDING)
for descendant in e.iterdescendants(tag=etree.Element):
if not attrs.intersection(descendant.attrib):
continue
self._pop_view_branding(descendant)
# TODO: find a better name and check if we have a string to boolean helper
return
node_path = e.get('data-oe-xpath')
if node_path is None:
node_path = "%s/%s[%d]" % (parent_xpath, e.tag, index_map[e.tag])
if branding:
if e.get('t-field'):
e.set('data-oe-xpath', node_path)
elif not e.get('data-oe-model'):
e.attrib.update(branding)
e.set('data-oe-xpath', node_path)
if not e.get('data-oe-model'):
return
if {'t-esc', 't-raw', 't-out'}.intersection(e.attrib):
# nodes which fully generate their content and have no reason to
# be branded because they can not sensibly be edited
self._pop_view_branding(e)
elif self._contains_branded(e):
# if a branded element contains branded elements distribute own
# branding to children unless it's t-raw, then just remove branding
# on current element
distributed_branding = self._pop_view_branding(e)
if 't-raw' not in e.attrib:
# TODO: collections.Counter if remove p2.6 compat
# running index by tag type, for XPath query generation
indexes = collections.defaultdict(lambda: 0)
for child in e.iterchildren(tag=etree.Element):
if child.get('data-oe-xpath'):
# injected by view inheritance, skip otherwise
# generated xpath is incorrect
# Also, if a node is known to have been replaced during applying xpath
# increment its index to compute an accurate xpath for susequent nodes
replaced_node_tag = child.attrib.pop('meta-oe-xpath-replacing', None)
if replaced_node_tag:
indexes[replaced_node_tag] += 1
self.distribute_branding(child)
else:
indexes[child.tag] += 1
self.distribute_branding(
child, distributed_branding,
parent_xpath=node_path, index_map=indexes)
def is_node_branded(self, node):
""" Finds out whether a node is branded or qweb-active (bears a
@data-oe-model or a @t-* *which is not t-field* as t-field does not
section out views)
:param node: an etree-compatible element to test
:type node: etree._Element
:rtype: boolean
"""
return any(
(attr in ('data-oe-model', 'groups') or (attr.startswith('t-')))
for attr in node.attrib
)
@tools.ormcache('self.id')
def get_view_xmlid(self):
domain = [('model', '=', 'ir.ui.view'), ('res_id', '=', self.id)]
xmlid = self.env['ir.model.data'].sudo().search_read(domain, ['module', 'name'])[0]
return '%s.%s' % (xmlid['module'], xmlid['name'])
@api.model
def render_public_asset(self, template, values=None):
template = self.sudo().browse(self.get_view_id(template))
template._check_view_access()
return template.sudo()._render(values, engine="ir.qweb")
def _render_template(self, template, values=None, engine='ir.qweb'):
return self.browse(self.get_view_id(template))._render(values, engine)
def _render(self, values=None, engine='ir.qweb', minimal_qcontext=False, options=None):
assert isinstance(self.id, int)
qcontext = dict() if minimal_qcontext else self._prepare_qcontext()
qcontext.update(values or {})
return self.env[engine]._render(self.id, qcontext, **(options or {}))
@api.model
def _prepare_qcontext(self):
""" Returns the qcontext : rendering context with website specific value (required
to render website layout template)
"""
qcontext = dict(
env=self.env,
user_id=self.env["res.users"].browse(self.env.user.id),
res_company=self.env.company.sudo(),
keep_query=keep_query,
request=request, # might be unbound if we're not in an httprequest context
debug=request.session.debug if request else '',
test_mode_enabled=bool(config['test_enable'] or config['test_file']),
json=json_scriptsafe,
quote_plus=werkzeug.urls.url_quote_plus,
time=safe_eval.time,
datetime=safe_eval.datetime,
relativedelta=relativedelta,
xmlid=self.sudo().key,
viewid=self.id,
to_text=pycompat.to_text,
image_data_uri=image_data_uri,
# specific 'math' functions to ease rounding in templates and lessen controller marshmalling
floor=math.floor,
ceil=math.ceil,
)
return qcontext
#------------------------------------------------------
# Misc
#------------------------------------------------------
def open_translations(self):
""" Open a view for editing the translations of field 'arch_db'. """
return self.env['ir.translation'].translate_fields('ir.ui.view', self.id, 'arch_db')
@api.model
def _validate_custom_views(self, model):
"""Validate architecture of custom views (= without xml id) for a given model.
This method is called at the end of registry update.
"""
query = """SELECT max(v.id)
FROM ir_ui_view v
LEFT JOIN ir_model_data md ON (md.model = 'ir.ui.view' AND md.res_id = v.id)
WHERE md.module IN (SELECT name FROM ir_module_module) IS NOT TRUE
AND v.model = %s
AND v.active = true
GROUP BY coalesce(v.inherit_id, v.id)"""
self._cr.execute(query, [model])
rec = self.browse(it[0] for it in self._cr.fetchall())
return rec.with_context({'load_all_views': True})._check_xml()
@api.model
def _validate_module_views(self, module):
""" Validate the architecture of all the views of a given module that
are impacted by view updates, but have not been checked yet.
"""
assert self.pool._init
# only validate the views that still exist...
prefix = module + '.'
prefix_len = len(prefix)
names = tuple(
xmlid[prefix_len:]
for xmlid in self.pool.loaded_xmlids
if xmlid.startswith(prefix)
)
if not names:
return
# retrieve the views with an XML id that has not been checked yet, i.e.,
# the views with noupdate=True on their xml id
query = """
SELECT v.id
FROM ir_ui_view v
JOIN ir_model_data md ON (md.model = 'ir.ui.view' AND md.res_id = v.id)
WHERE md.module = %s AND md.name IN %s AND md.noupdate
"""
self._cr.execute(query, (module, names))
views = self.browse([row[0] for row in self._cr.fetchall()])
for view in views:
view._check_xml()
def _create_all_specific_views(self, processed_modules):
"""To be overriden and have specific view behaviour on create"""
pass
def _get_specific_views(self):
""" Given a view, return a record set containing all the specific views
for that view's key.
"""
self.ensure_one()
# Only qweb views have a specific conterpart
if self.type != 'qweb':
return self.env['ir.ui.view']
# A specific view can have a xml_id if exported/imported but it will not be equals to it's key (only generic view will).
return self.with_context(active_test=False).search([('key', '=', self.key)]).filtered(lambda r: not r.xml_id == r.key)
def _load_records_write(self, values):
""" During module update, when updating a generic view, we should also
update its specific views (COW'd).
Note that we will only update unmodified fields. That will mimic the
noupdate behavior on views having an ir.model.data.
"""
if self.type == 'qweb':
for cow_view in self._get_specific_views():
authorized_vals = {}
for key in values:
if key != 'inherit_id' and cow_view[key] == self[key]:
authorized_vals[key] = values[key]
# if inherit_id update, replicate change on cow view but
# only if that cow view inherit_id wasn't manually changed
inherit_id = values.get('inherit_id')
if inherit_id and self.inherit_id.id != inherit_id and \
cow_view.inherit_id.key == self.inherit_id.key:
self._load_records_write_on_cow(cow_view, inherit_id, authorized_vals)
else:
cow_view.with_context(no_cow=True).write(authorized_vals)
super(View, self)._load_records_write(values)
def _load_records_write_on_cow(self, cow_view, inherit_id, values):
# for modules updated before `website`, we need to
# store the change to replay later on cow views
if not hasattr(self.pool, 'website_views_to_adapt'):
self.pool.website_views_to_adapt = []
self.pool.website_views_to_adapt.append((
cow_view.id,
inherit_id,
values,
))
class ResetViewArchWizard(models.TransientModel):
""" A wizard to compare and reset views architecture. """
_name = "reset.view.arch.wizard"
_description = "Reset View Architecture Wizard"
view_id = fields.Many2one('ir.ui.view', string='View')
view_name = fields.Char(related='view_id.name', string='View Name')
has_diff = fields.Boolean(compute='_compute_arch_diff')
arch_diff = fields.Html(string='Architecture Diff', readonly=True,
compute='_compute_arch_diff', sanitize_tags=False)
reset_mode = fields.Selection([
('soft', 'Restore previous version (soft reset).'),
('hard', 'Reset to file version (hard reset).'),
('other_view', 'Reset to another view.')],
string='Reset Mode', default='soft', required=True)
compare_view_id = fields.Many2one('ir.ui.view', string='Compare To View')
arch_to_compare = fields.Text('Arch To Compare To', compute='_compute_arch_diff')
@api.model
def default_get(self, fields):
view_ids = (self._context.get('active_model') == 'ir.ui.view' and
self._context.get('active_ids') or [])
if len(view_ids) > 2:
raise ValidationError(_("Can't compare more than two views."))
result = super().default_get(fields)
result['view_id'] = view_ids and view_ids[0]
if len(view_ids) == 2:
result['reset_mode'] = 'other_view'
result['compare_view_id'] = view_ids[1]
return result
@api.depends('reset_mode', 'view_id', 'compare_view_id')
def _compute_arch_diff(self):
""" Depending of `reset_mode`, return the differences between the
current view arch and either its previous arch, its initial arch or
another view arch.
"""
def get_table_name(view_id):
name = view_id.display_name
if view_id.key or view_id.xml_id:
span = '(%s)'
name += span % (view_id.key or view_id.xml_id)
return name
for view in self:
diff_to = False
diff_to_name = False
if view.reset_mode == 'soft':
diff_to = view.view_id.arch_prev
diff_to_name = _("Previous Arch")
elif view.reset_mode == 'other_view':
diff_to = view.compare_view_id.with_context(lang=None).arch
diff_to_name = get_table_name(view.compare_view_id)
elif view.reset_mode == 'hard' and view.view_id.arch_fs:
diff_to = view.view_id.with_context(read_arch_from_file=True, lang=None).arch
diff_to_name = _("File Arch")
view.arch_to_compare = diff_to
if not diff_to:
view.arch_diff = False
view.has_diff = False
else:
view_arch = view.view_id.with_context(lang=None).arch
view.arch_diff = get_diff(
(view_arch, get_table_name(view.view_id) if view.reset_mode == 'other_view' else _("Current Arch")),
(diff_to, diff_to_name),
)
view.has_diff = view_arch != diff_to
def reset_view_button(self):
self.ensure_one()
if self.reset_mode == 'other_view':
self.view_id.write({'arch_db': self.arch_to_compare})
else:
self.view_id.reset_arch(self.reset_mode)
return {'type': 'ir.actions.act_window_close'}
class NameManager:
""" An object that manages all the named elements in a view. """
def __init__(self, model):
self.model = model
self.available_fields = collections.defaultdict(dict) # {field_name: field_info}
self.available_actions = set()
self.available_names = set()
self.mandatory_fields = dict() # {field_name: use}
self.mandatory_parent_fields = dict() # {field_name: use}
self.mandatory_names = dict() # {name: use}
@lazy_property
def field_info(self):
return self.model.fields_get()
def has_field(self, name, info=frozendict()):
self.available_fields[name].update(info)
self.available_names.add(info.get('id') or name)
def has_action(self, name):
self.available_actions.add(name)
def must_have_field(self, name, use):
if name.startswith('parent.'):
self.mandatory_parent_fields[name[7:]] = use
else:
self.mandatory_fields[name] = use
def must_have_fields(self, names, use):
for name in names:
self.must_have_field(name, use)
def must_have_name(self, name, use):
self.mandatory_names[name] = use
def check(self, view):
# context for translations below
context = view.env.context # pylint: disable=unused-variable
for name, use in self.mandatory_names.items():
if name not in self.available_actions and name not in self.available_names:
msg = _(
"Name or id %(name_or_id)r in %(use)s must be present in view but is missing.",
name_or_id=name, use=use,
)
view._raise_view_error(msg)
for name in self.available_fields:
if name not in self.model._fields and name not in self.field_info:
message = _("Field `%(name)s` does not exist", name=name)
view._raise_view_error(message)
for name, use in self.mandatory_fields.items():
if name == 'id': # always available
continue
if "." in name:
msg = _(
"Invalid composed field %(definition)s in %(use)s",
definition=name, use=use,
)
view._raise_view_error(msg)
info = self.available_fields.get(name)
if info is None:
msg = _(
"Field %(name)r used in %(use)s must be present in view but is missing.",
name=name, use=use,
)
view._raise_view_error(msg)
if info.get('select') == 'multi': # mainly for searchpanel, but can be a generic behaviour.
msg = _(
"Field %(name)r used in %(use)s is present in view but is in select multi.",
name=name, use=use,
)
view._raise_view_error(msg)
def update_available_fields(self):
for name, info in self.available_fields.items():
info.update(self.field_info.get(name, ()))