# Part of Odoo. See LICENSE file for full copyright and licensing details. import ast import collections import datetime import functools import inspect import json import logging import math import pprint import re import time import uuid import warnings from lxml import etree from lxml.etree import LxmlError from lxml.builder import E import odoo from odoo import api, fields, models, tools, _ from odoo.exceptions import ValidationError, AccessError, UserError from odoo.http import request from odoo.modules.module import get_resource_from_path, get_resource_path from odoo.tools import config, ConstantMapping, get_diff, pycompat, apply_inheritance_specs, locate_node, str2bool from odoo.tools.convert import _fix_multiple_roots from odoo.tools import safe_eval, lazy, lazy_property, frozendict from odoo.tools.view_validation import valid_view, get_variable_names, get_domain_identifiers, get_dict_asts from odoo.tools.translate import xml_translate, TRANSLATED_ATTRS from odoo.models import check_method_name from odoo.osv.expression import expression _logger = logging.getLogger(__name__) MOVABLE_BRANDING = ['data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-xpath', 'data-oe-source-id'] ref_re = re.compile(r""" # first match 'form_view_ref' key, backrefs are used to handle single or # double quoting of the value (['"])(?P\w+_view_ref)\1 # colon separator (with optional spaces around) \s*:\s* # open quote for value (['"]) (?P # we'll just match stuff which is normally part of an xid: # word and "." characters [.\w]+ ) # close with same quote as opening \3 """, re.VERBOSE) def att_names(name): yield name yield f"t-att-{name}" yield f"t-attf-{name}" def transfer_field_to_modifiers(field, modifiers, attributes): default_values = {} state_exceptions = {} for attr in attributes: state_exceptions[attr] = [] default_values[attr] = bool(field.get(attr)) for state, modifs in field.get("states", {}).items(): for modif in modifs: if modif[0] in attributes and default_values[modif[0]] != modif[1]: state_exceptions[modif[0]].append(state) for attr, default_value in default_values.items(): if state_exceptions[attr]: modifiers[attr] = [("state", "not in" if default_value else "in", state_exceptions[attr])] else: modifiers[attr] = default_value def transfer_node_to_modifiers(node, modifiers): # Don't deal with groups, it is done by check_group(). attrs = node.attrib.pop('attrs', None) if attrs: modifiers.update(ast.literal_eval(attrs.strip())) for a in ('invisible', 'readonly', 'required'): if a in modifiers and isinstance(modifiers[a], int): modifiers[a] = bool(modifiers[a]) states = node.attrib.pop('states', None) if states: states = states.split(',') if 'invisible' in modifiers and isinstance(modifiers['invisible'], list): # TODO combine with AND or OR, use implicit AND for now. modifiers['invisible'].append(('state', 'not in', states)) else: modifiers['invisible'] = [('state', 'not in', states)] context_dependent_modifiers = {} for attr in ('invisible', 'readonly', 'required'): value_str = node.attrib.pop(attr, None) if value_str: if (attr == 'invisible' and any(parent.tag == 'tree' for parent in node.iterancestors()) and not any(parent.tag == 'header' for parent in node.iterancestors())): # Invisible in a tree view has a specific meaning, make it a # new key in the modifiers attribute. attr = 'column_invisible' # TODO: for invisible="context.get('...')", delegate to the web client. try: # most (~95%) elements are 1/True/0/False value = str2bool(value_str) except ValueError: # if str2bool fails, it means it's something else than 1/True/0/False, # meaning most-likely `context.get('...')`, # which should be evaluated after retrieving the view arch from the cache context_dependent_modifiers[attr] = value_str continue if value or (attr not in modifiers or not isinstance(modifiers[attr], list)): # Don't set the attribute to False if a dynamic value was # provided (i.e. a domain from attrs or states). modifiers[attr] = value if context_dependent_modifiers: node.set('context-dependent-modifiers', json.dumps(context_dependent_modifiers)) def simplify_modifiers(modifiers): for a in ('column_invisible', 'invisible', 'readonly', 'required'): if a in modifiers and not modifiers[a]: del modifiers[a] def transfer_modifiers_to_node(modifiers, node): if modifiers: simplify_modifiers(modifiers) if modifiers: node.set('modifiers', json.dumps(modifiers)) @lazy def keep_query(): mod = odoo.addons.base.models.ir_qweb warnings.warn(f"keep_query has been moved to {mod}", DeprecationWarning) return mod.keep_query class ViewCustom(models.Model): _name = 'ir.ui.view.custom' _description = 'Custom View' _order = 'create_date desc' # search(limit=1) should return the last customization _rec_name = 'user_id' ref_id = fields.Many2one('ir.ui.view', string='Original View', index=True, required=True, ondelete='cascade') user_id = fields.Many2one('res.users', string='User', index=True, required=True, ondelete='cascade') arch = fields.Text(string='View Architecture', required=True) def _auto_init(self): res = super(ViewCustom, self)._auto_init() tools.create_index(self._cr, 'ir_ui_view_custom_user_id_ref_id', self._table, ['user_id', 'ref_id']) return res def _hasclass(context, *cls): """ Checks if the context node has all the classes passed as arguments """ node_classes = set(context.context_node.attrib.get('class', '').split()) return node_classes.issuperset(cls) def get_view_arch_from_file(filepath, xmlid): module, view_id = xmlid.split('.') xpath = f"//*[@id='{xmlid}' or @id='{view_id}']" # when view is created from model with inheritS of ir_ui_view, the # xmlid has been suffixed by '_ir_ui_view'. We need to also search # for views without this prefix. if view_id.endswith('_ir_ui_view'): # len('_ir_ui_view') == 11 xpath = xpath[:-1] + f" or @id='{xmlid[:-11]}' or @id='{view_id[:-11]}']" document = etree.parse(filepath) for node in document.xpath(xpath): if node.tag == 'record': field_arch = node.find('field[@name="arch"]') if field_arch is not None: _fix_multiple_roots(field_arch) inner = ''.join( etree.tostring(child, encoding='unicode') for child in field_arch.iterchildren() ) return field_arch.text + inner field_view = node.find('field[@name="view_id"]') if field_view is not None: ref_module, _, ref_view_id = field_view.attrib.get('ref').rpartition('.') ref_xmlid = f'{ref_module or module}.{ref_view_id}' return get_view_arch_from_file(filepath, ref_xmlid) return None elif node.tag == 'template': # The following dom operations has been copied from convert.py's _tag_template() if not node.get('inherit_id'): node.set('t-name', xmlid) node.tag = 't' else: node.tag = 'data' node.attrib.pop('id', None) return etree.tostring(node, encoding='unicode') _logger.warning("Could not find view arch definition in file '%s' for xmlid '%s'", filepath, xmlid) return None xpath_utils = etree.FunctionNamespace(None) xpath_utils['hasclass'] = _hasclass TRANSLATED_ATTRS_RE = re.compile(r"@(%s)\b" % "|".join(TRANSLATED_ATTRS)) WRONGCLASS = re.compile(r"(@class\s*=|=\s*@class|contains\(@class)") class View(models.Model): _name = 'ir.ui.view' _description = 'View' _order = "priority,name,id" name = fields.Char(string='View Name', required=True) model = fields.Char(index=True) key = fields.Char(index='btree_not_null') priority = fields.Integer(string='Sequence', default=16, required=True) type = fields.Selection([('tree', 'Tree'), ('form', 'Form'), ('graph', 'Graph'), ('pivot', 'Pivot'), ('calendar', 'Calendar'), ('gantt', 'Gantt'), ('kanban', 'Kanban'), ('search', 'Search'), ('qweb', 'QWeb')], string='View Type') arch = fields.Text(compute='_compute_arch', inverse='_inverse_arch', string='View Architecture', help="""This field should be used when accessing view arch. It will use translation. Note that it will read `arch_db` or `arch_fs` if in dev-xml mode.""") arch_base = fields.Text(compute='_compute_arch_base', inverse='_inverse_arch_base', string='Base View Architecture', help="This field is the same as `arch` field without translations") arch_db = fields.Text(string='Arch Blob', translate=xml_translate, help="This field stores the view arch.") arch_fs = fields.Char(string='Arch Filename', help="""File from where the view originates. Useful to (hard) reset broken views or to read arch from file in dev-xml mode.""") arch_updated = fields.Boolean(string='Modified Architecture') arch_prev = fields.Text(string='Previous View Architecture', help="""This field will save the current `arch_db` before writing on it. Useful to (soft) reset a broken view.""") inherit_id = fields.Many2one('ir.ui.view', string='Inherited View', ondelete='restrict', index=True) inherit_children_ids = fields.One2many('ir.ui.view', 'inherit_id', string='Views which inherit from this one') field_parent = fields.Char(string='Child Field') model_data_id = fields.Many2one('ir.model.data', string="Model Data", compute='_compute_model_data_id', search='_search_model_data_id') xml_id = fields.Char(string="External ID", compute='_compute_xml_id', help="ID of the view defined in xml file") groups_id = fields.Many2many('res.groups', 'ir_ui_view_group_rel', 'view_id', 'group_id', string='Groups', help="If this field is empty, the view applies to all users. Otherwise, the view applies to the users of those groups only.") mode = fields.Selection([('primary', "Base view"), ('extension', "Extension View")], string="View inheritance mode", default='primary', required=True, help="""Only applies if this view inherits from an other one (inherit_id is not False/Null). * if extension (default), if this view is requested the closest primary view is looked up (via inherit_id), then all views inheriting from it with this view's model are applied * if primary, the closest primary view is fully resolved (even if it uses a different model than this one), then this view's inheritance specs () are applied, and the result is used as if it were this view's actual arch. """) # The "active" field is not updated during updates if