if 'modal' in classes and node.get('role') != 'dialog':
msg = '"modal" class should only be used with "dialog" role'
self.handle_view_error(msg, raise_exception=False)
if 'modal-header' in classes and node.tag != 'header':
msg = '"modal-header" class should only be used in "header" tag'
self.handle_view_error(msg, raise_exception=False)
if 'modal-body' in classes and node.tag != 'main':
msg = '"modal-body" class should only be used in "main" tag'
self.handle_view_error(msg, raise_exception=False)
if 'modal-footer' in classes and node.tag != 'footer':
msg = '"modal-footer" class should only be used in "footer" tag'
self.handle_view_error(msg, raise_exception=False)
if 'tab-pane' in classes and node.get('role') != 'tabpanel':
msg = '"tab-pane" class should only be used with "tabpanel" role'
self.handle_view_error(msg, raise_exception=False)
if 'nav-tabs' in classes and node.get('role') != 'tablist':
msg = 'A tab list with class nav-tabs must have role="tablist"'
self.handle_view_error(msg, raise_exception=False)
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.handle_view_error(msg, raise_exception=False)
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.handle_view_error(msg, raise_exception=False)
def _validate_fa_class_accessibility(self, node, description):
valid_aria_attrs = set(
self._att_list('title')
+ self._att_list('aria-label')
+ self._att_list('aria-labelledby')
)
valid_t_attrs = {'t-value', 't-raw', 't-field', 't-esc'}
## 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
## Aria label can be on ancestors
def has_title_or_aria_label(node):
return any(node.get(attr) for attr in valid_aria_attrs)
parent = node.getparent()
while parent is not None:
if has_title_or_aria_label(parent):
return
parent = parent.getparent()
## 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.tag == 'button' and node.get('string'):
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.handle_view_error(msg % description, raise_exception=False)
def _get_client_domain_variables(self, domain, key, expr):
""" Returns all field and variable names present in the given domain
(to be used client-side).
:param str: key (attrs.
)
:param str domain:
"""
try:
(field_names, var_names) = get_domain_identifiers(domain)
except ValueError:
msg = _(
'Invalid domain format while checking %(attribute)s in %(value)s',
attribute=expr, value=key,
)
self.handle_view_error(msg)
return dict.fromkeys(field_names | var_names, '%s (%s)' % (key, expr))
def _get_server_domain_variables(self, domain, key, Model):
""" Returns all the variable names present in the given domain (to be
used server-side).
"""
try:
(field_names, var_names) = get_domain_identifiers(domain)
except ValueError as e:
msg = _('Invalid domain format while checking %s in %s', domain, key)
self.handle_view_error(msg, from_traceback=e.__traceback__)
# checking field names
for name_seq in field_names:
fnames = name_seq.split('.')
model = Model
try:
for fname in fnames:
if not isinstance(model, models.BaseModel):
msg = _(
'Trying to access "%(field)s" on %(model)s in path %(field_path)r in %(attribute)s=%(value)r',
field=fname, model=model, field_path=name_seq, attribute=key, value=domain,
)
self.handle_view_error(msg)
field = model._fields[fname]
if not field._description_searchable:
msg = _(
'Unsearchable field "%(field)s" in path %(field_path)r in %(attribute)s=%(value)r',
field=field, field_path=name_seq, attribute=key, value=domain,
)
self.handle_view_error(msg)
model = model[fname]
except KeyError:
msg = _(
'Unknown field "%(model)s.%(field)s" in %(attribute)s%(value)r',
model=model._name, field=fname, attribute=key, value=domain,
)
self.handle_view_error(msg)
return dict.fromkeys(var_names, "%s (%s)" % (key, domain))
def _get_field_domain_variables(self, node, field, editable):
""" Return the variable names present in the field's domain, if no
domain is given on the node itself.
"""
if editable and not node.get('domain') and field.relational:
domain = field._description_domain(self.env)
if isinstance(domain, str):
return self._get_server_domain_variables(
domain,
'field %s default domain' % field.name,
self.env[field.comodel_name],
)
return {}
#------------------------------------------------------
# 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
@tools.conditional(
'xml' not in config['dev_mode'],
tools.ormcache('frozenset(self.env.user.groups_id.ids)', 'view_id',
'tuple(self._context.get(k) for k in self._read_template_keys())'),
)
def _read_template(self, view_id):
arch = self.browse(view_id).read_combined(['arch'])['arch']
arch_tree = etree.fromstring(arch)
self.distribute_branding(arch_tree)
arch = etree.tostring(arch_tree, encoding='unicode')
return arch
@api.model
def read_template(self, xml_id):
""" This method is deprecated
Return a template content based on external id
Read access on ir.ui.view required
"""
template_id = self.get_view_id(xml_id)
self.browse(template_id)._check_view_access()
return self._read_template(template_id)
@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'):
# Note: 'data-oe-field-xpath' and not 'data-oe-xpath' as this
# was introduced as a fix. To avoid breaking customizations and
# to make a minimal diff fix, a separated attribute was used.
# TODO Try to use a common attribute in master (14.1).
e.set('data-oe-field-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'}.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') or child.get('data-oe-field-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):
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)
@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:
try:
view._check_xml()
except Exception as e:
view.handle_view_error("Can't validate view:\n%s" % e)
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, validate, Model):
self.available_fields = dict()
self.mandatory_fields = dict()
self.mandatory_parent_fields = dict()
self.available_actions = set()
self.mandatory_names_or_ids = dict()
self.available_names_or_ids = set()
self.validate = validate
self.Model = Model
self.fields_get = self.Model.fields_get()
def has_field(self, name, info=()):
self.available_fields.setdefault(name, {}).update(info)
self.available_names_or_ids.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, name_uses):
for name, use in name_uses.items():
self.must_have_field(name, use)
def must_have_name_or_id(self, name, use):
self.mandatory_names_or_ids[name] = use
def final_check(self):
if self.mandatory_fields:
msg = []
for field in self.mandatory_fields:
msg.append(str(field))
_logger.error("All parent.field should have been consummed at root level. \n %s", '\n'.join(msg))
def check_view_fields(self, view):
if not self.validate:
return
for action, use in self.mandatory_names_or_ids.items():
if action not in self.available_actions and action not in self.available_names_or_ids:
msg = _(
"Name or id '%(name_or_id)s' used in '%(use)s' must be present in view but is missing.",
name_or_id=action, use=use,
)
view.handle_view_error(msg)
for field_name in self.available_fields:
if field_name not in self.fields_get:
message = _("Field `%(name)s` does not exist", name=field_name)
view.handle_view_error(message)
for field, use in self.mandatory_fields.items():
if field == 'id': # always available
continue
if "." in field:
msg = _(
"Invalid composed field %(definition)s in %(use)s",
definition=field, use=use,
)
view.handle_view_error(msg)
corresponding_field = self.available_fields.get(str(field))
if corresponding_field is None:
msg = _(
"Field %(name)s used in %(use)s must be present in view but is missing.",
name=field, use=use,
)
view.handle_view_error(msg)
if corresponding_field.get('select') == 'multi': # mainly for searchpanel, but can be a generic behaviour.
msg = _(
"Field %(name)s used in %(use)s is present in view but is in select multi.",
name=field, use=use,
)
view.handle_view_error(msg)
def update_view_fields(self):
for field_name, field_infos in self.available_fields.items():
model_field_infos = self.fields_get.get(field_name)
if model_field_infos:
field_infos.update(model_field_infos)