Odoo第三方模块V16.0>V17.0记录

升级第三方模块

借助OCA的odoo-module-migrator工具来进行模块代码升级

# 进入容器
exec --user root -it odoo17 /bin/bash
apt update
apt install -y git
# 在容器内执行
cd /tmp
git clone https://github.com/OCA/odoo-module-migrator.git
cd odoo-module-migrator
pip3 install -r requirements.txt
# 查看命令行帮助
python3 -m odoo_module_migrate -h
# 升级模块
python3 -m odoo_module_migrate -d /mnt/extra-addons/ -m module_name -i 12.0 -t 17.0

升级模块之后会自动commit变更

升级视图

Odoo17移除了视图上的attrs与states,改为使用readonly,invisible,required等属性,这样会导致一些视图无法正常显示,需要手动修改视图文件(PR:https://github.com/odoo/odoo/pull/104741)

PR正文末尾提到

Script to convert your files: https://github.com/Gorash/odoo-view-pocalypse-convert
通过应用这个patch得到一个打好补丁的convert.py,然后把它复制到容器内替换odoo/tools/convert.py

# 进入容器
exec --user root -it odoo17 /bin/bash
# 容器内
cd /usr/lib/python3/dist-packages/odoo/tools/
mv convert.py convert.py_bak
# 退出容器,把打好补丁的convert.py复制进去
docker cp E:\convert.py odoo17:/usr/lib/python3/dist-packages/odoo/tools/

之后在升级模块的时候它会转换xml文件中的attrs与states

convert.py

感谢jeffery大大帮忙patch

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

__all__ = [
    'convert_file', 'convert_sql_import',
    'convert_csv_import', 'convert_xml_import'
]

import ast
import base64
import io
import logging
import os.path
import pprint
import re
import subprocess
import warnings

from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta

import pytz
from lxml import etree, builder
try:
    import jingtrang
except ImportError:
    jingtrang = None

import odoo
from. import mute_logger
from . import pycompat
from .config import config
from .misc import file_open, unquote, ustr, SKIPPED_ELEMENT_TYPES
from .translate import _
from .template_inheritance import locate_node
from odoo import SUPERUSER_ID, api
from odoo.exceptions import ValidationError

_logger = logging.getLogger(__name__)

from .view_validation import get_expression_field_names

from .safe_eval import safe_eval as s_eval, pytz, time
safe_eval = lambda expr, ctx={}: s_eval(expr, ctx, nocopy=True)

class ParseError(Exception):
    ...

class RecordDictWrapper(dict):
    """
    Used to pass a record as locals in eval:
    records do not strictly behave like dict, so we force them to.
    """
    def __init__(self, record):
        self.record = record
    def __getitem__(self, key):
        if key in self.record:
            return self.record[key]
        return dict.__getitem__(self, key)

def _get_idref(self, env, model_str, idref):
    idref2 = dict(idref,
                  Command=odoo.fields.Command,
                  time=time,
                  DateTime=datetime,
                  datetime=datetime,
                  timedelta=timedelta,
                  relativedelta=relativedelta,
                  version=odoo.release.major_version,
                  ref=self.id_get,
                  pytz=pytz)
    if model_str:
        idref2['obj'] = env[model_str].browse
    return idref2

def _fix_multiple_roots(node):
    """
    Surround the children of the ``node`` element of an XML field with a
    single root "data" element, to prevent having a document with multiple
    roots once parsed separately.

    XML nodes should have one root only, but we'd like to support
    direct multiple roots in our partial documents (like inherited view architectures).
    As a convention we'll surround multiple root with a container "data" element, to be
    ignored later when parsing.
    """
    real_nodes = [x for x in node if not isinstance(x, SKIPPED_ELEMENT_TYPES)]
    if len(real_nodes) > 1:
        data_node = etree.Element("data")
        for child in node:
            data_node.append(child)
        node.append(data_node)

def _eval_xml(self, node, env):
    if node.tag in ('field','value'):
        t = node.get('type','char')
        f_model = node.get('model')
        if node.get('search'):
            f_search = node.get("search")
            f_use = node.get("use",'id')
            f_name = node.get("name")
            idref2 = {}
            if f_search:
                idref2 = _get_idref(self, env, f_model, self.idref)
            q = safe_eval(f_search, idref2)
            ids = env[f_model].search(q).ids
            if f_use != 'id':
                ids = [x[f_use] for x in env[f_model].browse(ids).read([f_use])]
            _fields = env[f_model]._fields
            if (f_name in _fields) and _fields[f_name].type == 'many2many':
                return ids
            f_val = False
            if len(ids):
                f_val = ids[0]
                if isinstance(f_val, tuple):
                    f_val = f_val[0]
            return f_val
        a_eval = node.get('eval')
        if a_eval:
            idref2 = _get_idref(self, env, f_model, self.idref)
            try:
                return safe_eval(a_eval, idref2)
            except Exception:
                logging.getLogger('odoo.tools.convert.init').error(
                    'Could not eval(%s) for %s in %s', a_eval, node.get('name'), env.context)
                raise
        def _process(s):
            matches = re.finditer(br'[^%]%\((.*?)\)[ds]'.decode('utf-8'), s)
            done = set()
            for m in matches:
                found = m.group()[1:]
                if found in done:
                    continue
                done.add(found)
                id = m.groups()[0]
                if not id in self.idref:
                    self.idref[id] = self.id_get(id)
                # So funny story: in Python 3, bytes(n: int) returns a
                # bytestring of n nuls. In Python 2 it obviously returns the
                # stringified number, which is what we're expecting here
                s = s.replace(found, str(self.idref[id]))
            s = s.replace('%%', '%') # Quite weird but it's for (somewhat) backward compatibility sake
            return s

        if t == 'xml':
            _fix_multiple_roots(node)
            return '<?xml version="1.0"?>\n'\
                +_process("".join(etree.tostring(n, encoding='unicode') for n in node))
        if t == 'html':
            return _process("".join(etree.tostring(n, method='html', encoding='unicode') for n in node))

        data = node.text
        if node.get('file'):
            with file_open(node.get('file'), 'rb', env=env) as f:
                data = f.read()

        if t == 'base64':
            return base64.b64encode(data)

        # after that, only text content makes sense
        data = pycompat.to_text(data)
        if t == 'file':
            from ..modules import module
            path = data.strip()
            if not module.get_module_resource(self.module, path):
                raise IOError("No such file or directory: '%s' in %s" % (
                    path, self.module))
            return '%s,%s' % (self.module, path)

        if t == 'char':
            return data

        if t == 'int':
            d = data.strip()
            if d == 'None':
                return None
            return int(d)

        if t == 'float':
            return float(data.strip())

        if t in ('list','tuple'):
            res=[]
            for n in node.iterchildren(tag='value'):
                res.append(_eval_xml(self, n, env))
            if t=='tuple':
                return tuple(res)
            return res
    elif node.tag == "function":
        model_str = node.get('model')
        model = env[model_str]
        method_name = node.get('name')
        # determine arguments
        args = []
        kwargs = {}
        a_eval = node.get('eval')

        if a_eval:
            idref2 = _get_idref(self, env, model_str, self.idref)
            args = list(safe_eval(a_eval, idref2))
        for child in node:
            if child.tag == 'value' and child.get('name'):
                kwargs[child.get('name')] = _eval_xml(self, child, env)
            else:
                args.append(_eval_xml(self, child, env))
        # merge current context with context in kwargs
        kwargs['context'] = {**env.context, **kwargs.get('context', {})}
        # invoke method
        return odoo.api.call_kw(model, method_name, args, kwargs)
    elif node.tag == "test":
        return node.text


def nodeattr2bool(node, attr, default=False):
    if not node.get(attr):
        return default
    val = node.get(attr).strip()
    if not val:
        return default
    return val.lower() not in ('0', 'false', 'off')

class xml_import(object):
    def get_env(self, node, eval_context=None):
        uid = node.get('uid')
        context = node.get('context')
        if uid or context:
            return self.env(
                user=uid and self.id_get(uid),
                context=context and {
                    **self.env.context,
                    **safe_eval(context, {
                        'ref': self.id_get,
                        **(eval_context or {})
                    })
                }
            )
        return self.env

    def make_xml_id(self, xml_id):
        if not xml_id or '.' in xml_id:
            return xml_id
        return "%s.%s" % (self.module, xml_id)

    def _test_xml_id(self, xml_id):
        if '.' in xml_id:
            module, id = xml_id.split('.', 1)
            assert '.' not in id, """The ID reference "%s" must contain
maximum one dot. They are used to refer to other modules ID, in the
form: module.record_id""" % (xml_id,)
            if module != self.module:
                modcnt = self.env['ir.module.module'].search_count([('name', '=', module), ('state', '=', 'installed')])
                assert modcnt == 1, """The ID "%s" refers to an uninstalled module""" % (xml_id,)

    def _tag_delete(self, rec):
        d_model = rec.get("model")
        records = self.env[d_model]

        d_search = rec.get("search")
        if d_search:
            idref = _get_idref(self, self.env, d_model, {})
            try:
                records = records.search(safe_eval(d_search, idref))
            except ValueError:
                _logger.warning('Skipping deletion for failed search `%r`', d_search, exc_info=True)

        d_id = rec.get("id")
        if d_id:
            try:
                records += records.browse(self.id_get(d_id))
            except ValueError:
                # d_id cannot be found. doesn't matter in this case
                _logger.warning('Skipping deletion for missing XML ID `%r`', d_id, exc_info=True)

        if records:
            records.unlink()

    def _tag_function(self, rec):
        if self.noupdate and self.mode != 'init':
            return
        env = self.get_env(rec)
        _eval_xml(self, rec, env)

    def _tag_menuitem(self, rec, parent=None):
        rec_id = rec.attrib["id"]
        self._test_xml_id(rec_id)

        # The parent attribute was specified, if non-empty determine its ID, otherwise
        # explicitly make a top-level menu
        values = {
            'parent_id': False,
            'active': nodeattr2bool(rec, 'active', default=True),
        }

        if rec.get('sequence'):
            values['sequence'] = int(rec.get('sequence'))

        if parent is not None:
            values['parent_id'] = parent
        elif rec.get('parent'):
            values['parent_id'] = self.id_get(rec.attrib['parent'])
        elif rec.get('web_icon'):
            values['web_icon'] = rec.attrib['web_icon']


        if rec.get('name'):
            values['name'] = rec.attrib['name']

        if rec.get('action'):
            a_action = rec.attrib['action']

            if '.' not in a_action:
                a_action = '%s.%s' % (self.module, a_action)
            act = self.env.ref(a_action).sudo()
            values['action'] = "%s,%d" % (act.type, act.id)

            if not values.get('name') and act.type.endswith(('act_window', 'wizard', 'url', 'client', 'server')) and act.name:
                values['name'] = act.name

        if not values.get('name'):
            values['name'] = rec_id or '?'


        groups = []
        for group in rec.get('groups', '').split(','):
            if group.startswith('-'):
                group_id = self.id_get(group[1:])
                groups.append(odoo.Command.unlink(group_id))
            elif group:
                group_id = self.id_get(group)
                groups.append(odoo.Command.link(group_id))
        if groups:
            values['groups_id'] = groups


        data = {
            'xml_id': self.make_xml_id(rec_id),
            'values': values,
            'noupdate': self.noupdate,
        }
        menu = self.env['ir.ui.menu']._load_records([data], self.mode == 'update')
        for child in rec.iterchildren('menuitem'):
            self._tag_menuitem(child, parent=menu.id)

    def _tag_record(self, rec, extra_vals=None):
        rec_model = rec.get("model")
        env = self.get_env(rec)
        rec_id = rec.get("id", '')

        model = env[rec_model]

        if self.xml_filename and rec_id:
            model = model.with_context(
                install_module=self.module,
                install_filename=self.xml_filename,
                install_xmlid=rec_id,
            )

        self._test_xml_id(rec_id)
        xid = self.make_xml_id(rec_id)

        # in update mode, the record won't be updated if the data node explicitly
        # opt-out using @noupdate="1". A second check will be performed in
        # model._load_records() using the record's ir.model.data `noupdate` field.
        if self.noupdate and self.mode != 'init':
            # check if the xml record has no id, skip
            if not rec_id:
                return None

            record = env['ir.model.data']._load_xmlid(xid)
            if record:
                # if the resource already exists, don't update it but store
                # its database id (can be useful)
                self.idref[rec_id] = record.id
                return None
            elif not nodeattr2bool(rec, 'forcecreate', True):
                # if it doesn't exist and we shouldn't create it, skip it
                return None
            # else create it normally

        if xid and xid.partition('.')[0] != self.module:
            # updating a record created by another module
            record = self.env['ir.model.data']._load_xmlid(xid)
            if not record:
                if self.noupdate and not nodeattr2bool(rec, 'forcecreate', True):
                    # if it doesn't exist and we shouldn't create it, skip it
                    return None
                raise Exception("Cannot update missing record %r" % xid)

        if rec_model == 'ir.ui.view':
            _convert_ir_ui_view_modifiers(self, rec, extra_vals=extra_vals)

        res = {}
        sub_records = []
        for field in rec.findall('./field'):
            #TODO: most of this code is duplicated above (in _eval_xml)...
            f_name = field.get("name")
            f_ref = field.get("ref")
            f_search = field.get("search")
            f_model = field.get("model")
            if not f_model and f_name in model._fields:
                f_model = model._fields[f_name].comodel_name
            f_use = field.get("use",'') or 'id'
            f_val = False

            if f_search:
                idref2 = _get_idref(self, env, f_model, self.idref)
                q = safe_eval(f_search, idref2)
                assert f_model, 'Define an attribute model="..." in your .XML file!'
                # browse the objects searched
                s = env[f_model].search(q)
                # column definitions of the "local" object
                _fields = env[rec_model]._fields
                # if the current field is many2many
                if (f_name in _fields) and _fields[f_name].type == 'many2many':
                    f_val = [odoo.Command.set([x[f_use] for x in s])]
                elif len(s):
                    # otherwise (we are probably in a many2one field),
                    # take the first element of the search
                    f_val = s[0][f_use]
            elif f_ref:
                if f_name in model._fields and model._fields[f_name].type == 'reference':
                    val = self.model_id_get(f_ref)
                    f_val = val[0] + ',' + str(val[1])
                else:
                    f_val = self.id_get(f_ref, raise_if_not_found=nodeattr2bool(rec, 'forcecreate', True))
                    if not f_val:
                        _logger.warning("Skipping creation of %r because %s=%r could not be resolved", xid, f_name, f_ref)
                        return None
            else:
                f_val = _eval_xml(self, field, env)
                if f_name in model._fields:
                    field_type = model._fields[f_name].type
                    if field_type == 'many2one':
                        f_val = int(f_val) if f_val else False
                    elif field_type == 'integer':
                        f_val = int(f_val)
                    elif field_type in ('float', 'monetary'):
                        f_val = float(f_val)
                    elif field_type == 'boolean' and isinstance(f_val, str):
                        f_val = f_val.lower() not in ('0', 'false', 'off')
                    elif field_type == 'one2many':
                        for child in field.findall('./record'):
                            sub_records.append((child, model._fields[f_name].inverse_name))
                        if isinstance(f_val, str):
                            # We do not want to write on the field since we will write
                            # on the childrens' parents later
                            continue
                    elif field_type == 'html':
                        if field.get('type') == 'xml':
                            _logger.warning('HTML field %r is declared as `type="xml"`', f_name)
            res[f_name] = f_val
        if extra_vals:
            res.update(extra_vals)

        data = dict(xml_id=xid, values=res, noupdate=self.noupdate)
        try:
            record = model._load_records([data], self.mode == 'update')
        except Exception as e:
            _logger.error(f'Fail to create: {rec_id!r} from {self.xml_filename}\n{e}')
            return None
            raise
        if rec_id:
            self.idref[rec_id] = record.id
        if config.get('import_partial'):
            env.cr.commit()
        for child_rec, inverse_name in sub_records:
            self._tag_record(child_rec, extra_vals={inverse_name: record.id})

        return rec_model, record.id

    def _tag_template(self, el):
        # This helper transforms a <template> element into a <record> and forwards it
        tpl_id = el.get('id', el.get('t-name'))
        full_tpl_id = tpl_id
        if '.' not in full_tpl_id:
            full_tpl_id = '%s.%s' % (self.module, tpl_id)
        # set the full template name for qweb <module>.<id>
        if not el.get('inherit_id'):
            el.set('t-name', full_tpl_id)
            el.tag = 't'
        else:
            el.tag = 'data'
        el.attrib.pop('id', None)

        if self.module.startswith('theme_'):
            model = 'theme.ir.ui.view'
        else:
            model = 'ir.ui.view'

        record_attrs = {
            'id': tpl_id,
            'model': model,
        }
        for att in ['forcecreate', 'context']:
            if att in el.attrib:
                record_attrs[att] = el.attrib.pop(att)

        Field = builder.E.field
        name = el.get('name', tpl_id)

        record = etree.Element('record', attrib=record_attrs)
        record.append(Field(name, name='name'))
        record.append(Field(full_tpl_id, name='key'))
        record.append(Field("qweb", name='type'))
        if 'track' in el.attrib:
            record.append(Field(el.get('track'), name='track'))
        if 'priority' in el.attrib:
            record.append(Field(el.get('priority'), name='priority'))
        if 'inherit_id' in el.attrib:
            record.append(Field(name='inherit_id', ref=el.get('inherit_id')))
        if 'website_id' in el.attrib:
            record.append(Field(name='website_id', ref=el.get('website_id')))
        if 'key' in el.attrib:
            record.append(Field(el.get('key'), name='key'))
        if el.get('active') in ("True", "False"):
            view_id = self.id_get(tpl_id, raise_if_not_found=False)
            if self.mode != "update" or not view_id:
                record.append(Field(name='active', eval=el.get('active')))
        if el.get('customize_show') in ("True", "False"):
            record.append(Field(name='customize_show', eval=el.get('customize_show')))
        groups = el.attrib.pop('groups', None)
        if groups:
            grp_lst = [("ref('%s')" % x) for x in groups.split(',')]
            record.append(Field(name="groups_id", eval="[Command.set(["+', '.join(grp_lst)+"])]"))
        if el.get('primary') == 'True':
            # Pseudo clone mode, we'll set the t-name to the full canonical xmlid
            el.append(
                builder.E.xpath(
                    builder.E.attribute(full_tpl_id, name='t-name'),
                    expr=".",
                    position="attributes",
                )
            )
            record.append(Field('primary', name='mode'))
        # inject complete <template> element (after changing node name) into
        # the ``arch`` field
        record.append(Field(el, name="arch", type="xml"))

        return self._tag_record(record)

    def id_get(self, id_str, raise_if_not_found=True):
        if id_str in self.idref:
            return self.idref[id_str]
        res = self.model_id_get(id_str, raise_if_not_found)
        return res and res[1]

    def model_id_get(self, id_str, raise_if_not_found=True):
        if '.' not in id_str:
            id_str = '%s.%s' % (self.module, id_str)
        return self.env['ir.model.data']._xmlid_to_res_model_res_id(id_str, raise_if_not_found=raise_if_not_found)

    def _tag_root(self, el):
        for rec in el:
            f = self._tags.get(rec.tag)
            if f is None:
                continue

            self.envs.append(self.get_env(el))
            self._noupdate.append(nodeattr2bool(el, 'noupdate', self.noupdate))
            try:
                f(rec)
            except ParseError:
                raise
            except ValidationError as err:
                msg = "while parsing {file}:{viewline}\n{err}\n\nView error context:\n{context}\n".format(
                    file=rec.getroottree().docinfo.URL,
                    viewline=rec.sourceline,
                    context=pprint.pformat(getattr(err, 'context', None) or '-no context-'),
                    err=err.args[0],
                )
                _logger.debug(msg, exc_info=True)
                raise err
                raise ParseError(msg) from None  # Restart with "--log-handler odoo.tools.convert:DEBUG" for complete traceback
            except Exception as e:
                raise ParseError('while parsing %s:%s, somewhere inside\n%s' % (
                    rec.getroottree().docinfo.URL,
                    rec.sourceline,
                    etree.tostring(rec, encoding='unicode').rstrip()
                )) from e
            finally:
                self._noupdate.pop()
                self.envs.pop()

    @property
    def env(self):
        return self.envs[-1]

    @property
    def noupdate(self):
        return self._noupdate[-1]

    def __init__(self, env, module, idref, mode, noupdate=False, xml_filename=None):
        self.mode = mode
        self.module = module
        self.envs = [env(context=dict(env.context, lang=None))]
        self.idref = {} if idref is None else idref
        self._noupdate = [noupdate]
        self.xml_filename = xml_filename
        self._tags = {
            'record': self._tag_record,
            'delete': self._tag_delete,
            'function': self._tag_function,
            'menuitem': self._tag_menuitem,
            'template': self._tag_template,

            **dict.fromkeys(self.DATA_ROOTS, self._tag_root)
        }

    def parse(self, de):
        assert de.tag in self.DATA_ROOTS, "Root xml tag must be <openerp>, <odoo> or <data>."
        self._tag_root(de)
    DATA_ROOTS = ['odoo', 'data', 'openerp']

def convert_file(env, module, filename, idref, mode='update', noupdate=False, kind=None, pathname=None):
    if pathname is None:
        pathname = os.path.join(module, filename)
    ext = os.path.splitext(filename)[1].lower()

    with file_open(pathname, 'rb') as fp:
        if ext == '.csv':
            convert_csv_import(env, module, pathname, fp.read(), idref, mode, noupdate)
        elif ext == '.sql':
            convert_sql_import(env, fp)
        elif ext == '.xml':
            convert_xml_import(env, module, fp, idref, mode, noupdate)
        elif ext == '.js':
            pass # .js files are valid but ignored here.
        else:
            raise ValueError("Can't load unknown file type %s.", filename)

def convert_sql_import(env, fp):
    env.cr.execute(fp.read()) # pylint: disable=sql-injection

def convert_csv_import(env, module, fname, csvcontent, idref=None, mode='init',
        noupdate=False):
    '''Import csv file :
        quote: "
        delimiter: ,
        encoding: utf-8'''
    env = env(context=dict(env.context, lang=None))
    filename, _ext = os.path.splitext(os.path.basename(fname))
    model = filename.split('-')[0]
    reader = pycompat.csv_reader(io.BytesIO(csvcontent), quotechar='"', delimiter=',')
    fields = next(reader)

    if not (mode == 'init' or 'id' in fields):
        _logger.error("Import specification does not contain 'id' and we are in init mode, Cannot continue.")
        return

    # filter out empty lines (any([]) == False) and lines containing only empty cells
    datas = [
        line for line in reader
        if any(line)
    ]

    context = {
        'mode': mode,
        'module': module,
        'install_module': module,
        'install_filename': fname,
        'noupdate': noupdate,
    }
    result = env[model].with_context(**context).load(fields, datas)
    if any(msg['type'] == 'error' for msg in result['messages']):
        # Report failed import and abort module install
        warning_msg = "\n".join(msg['message'] for msg in result['messages'])
        raise Exception(_('Module loading %s failed: file %s could not be processed:\n %s') % (module, fname, warning_msg))

def convert_xml_import(env, module, xmlfile, idref=None, mode='init', noupdate=False, report=None):
    doc = etree.parse(xmlfile)
    schema = os.path.join(config['root_path'], 'import_xml.rng')
    relaxng = etree.RelaxNG(etree.parse(schema))
    try:
        relaxng.assert_(doc)
    except Exception:
        _logger.exception("The XML file '%s' does not fit the required schema!", xmlfile.name)
        if jingtrang:
            p = subprocess.run(['pyjing', schema, xmlfile.name], stdout=subprocess.PIPE)
            _logger.warning(p.stdout.decode())
        else:
            for e in relaxng.error_log:
                _logger.warning(e)
            _logger.info("Install 'jingtrang' for more precise and useful validation messages.")
        raise

    if isinstance(xmlfile, str):
        xml_filename = xmlfile
    else:
        xml_filename = xmlfile.name
    obj = xml_import(env, module, idref, mode, noupdate=noupdate, xml_filename=xml_filename)
    obj.parse(doc.getroot())



#######################################################################


def _convert_ir_ui_view_modifiers(self, record_node, extra_vals=None):
    rec_id = record_node.get("id", '')
    f_model = record_node.find('field[@name="model"]')
    f_type = record_node.find('field[@name="type"]')
    f_inherit = record_node.find('field[@name="inherit_id"]')
    f_arch = record_node.find('field[@name="arch"]')
    root = f_arch if f_arch is not None else record_node

    ref = f'{rec_id} ({self.xml_filename})'

    try:
        data_id = f_inherit is not None and f_inherit.get('ref')
        inherit = None
        if data_id:
            if '.' not in data_id:
                data_id = f'{self.module}.{data_id}'
            inherit = self.env.ref(data_id)

        model_name = f_model is not None and f_model.text
        if not model_name and inherit:
            model_name = inherit.model
        if not model_name:
            return

        view_type = f_type is not None and f_type.text or root[0].tag
        if inherit:
            view_type = inherit.type

        if view_type not in ('kanban', 'tree', 'form', 'calendar', 'setting', 'search'):
            return

        # load previous arch
        arch = None
        previous_xml = file_open(self.xml_filename, 'r').read()
        match = re.search(rf'''(<record [^>]*id=['"]{rec_id}['"][^>]*>(?:[^<]|<(?!/record>))+</record>)''', previous_xml)
        if not match:
            _logger.error(f"Can not found {rec_id!r} from {self.xml_filename}")
            return
        record_xml = match.group(1)

        match = re.search(rf'''(<field [^>]*name=["']arch["'][^>]*>((.|\n)+)</field>)''', record_xml)
        if not match:
            _logger.error(f"Can not found arch of {rec_id!r} from {self.xml_filename}")
            return
        arch = match.group(2).strip()

        # load inherited arch
        inherited_root = inherit and etree.fromstring(inherit.get_combined_arch())

        head = False
        added_data = False
        arch_clean = arch
        if arch_clean.startswith('<?'):
            head, arch_clean = arch_clean.split('\n', 1)
        if not arch_clean.startswith('<data>'):
            added_data = True
            arch_clean = f'<data>{arch_clean}</data>'
        root_content = etree.fromstring(arch_clean)
        model = self.env[model_name]

        try:
            arch_result = convert_template_modifiers(self.env, arch_clean, root_content, model, view_type, ref, inherited_root=inherited_root)
        except Exception as e:
            _logger.error(f"Can not convert: {rec_id!r} from {self.xml_filename}\n{e}")
            return

        if re.sub(rf'(\n| )*{reg_comment}(\n| )*', '', arch_result) == '<data></data>':
            _logger.error(f'No uncommented element found: {rec_id!r} from {self.xml_filename}')
            arch_result = arch_result[:6] + '<field position="attributes" help="add to avoid error when convert"/>' + arch_result[6:]

        if added_data:
            arch_result = arch_result[6:-7]
        if head:
            arch_result = head + arch_result

        if arch_result != arch:
            if added_data:
                while len(f_arch): f_arch.remove(f_arch[0])
            for n in root_content:
                f_arch.append(n)
            f_arch.text = root_content.text

            new_xml = previous_xml.replace(arch, arch_result)
            with file_open(self.xml_filename, 'w') as file:
                file.write(new_xml)
            try:
                # test file before save
                etree.fromstring(new_xml.encode())
            except Exception as e:
                _logger.error(f'Wrong view conversion in {rec_id!r} from {self.xml_filename}\n\n{arch}\n\n{e}')
                return

    except Exception as e:
        _logger.error('FAIL ! %s\n%s', ref, e)

import itertools
from odoo import tools
from odoo.tools.misc import unique, str2bool
from odoo.tools import locate_node, mute_logger, apply_inheritance_specs
from odoo.tools.view_validation import get_expression_field_names, _get_expression_contextual_values, get_domain_value_names
from odoo.osv.expression import (
    DOMAIN_OPERATORS,
    TERM_OPERATORS, AND_OPERATOR, OR_OPERATOR, NOT_OPERATOR,
    normalize_domain,
    distribute_not,
    TRUE_LEAF, FALSE_LEAF,
)
from .safe_eval import _BUILTINS
VALID_TERM_OPERATORS = TERM_OPERATORS + ("<>", "==")
AST_OP_TO_STR = {
    ast.Eq: "==",
    ast.NotEq: "!=",
    ast.Lt: "<",
    ast.LtE: "<=",
    ast.Gt: ">",
    ast.GtE: ">=",
    ast.Is: "is",
    ast.IsNot: "is not",
    ast.In: "in",
    ast.NotIn: "not in",
    ast.Add: "+",
    ast.Sub: "-",
    ast.Mult: "*",
    ast.Div: "/",
    ast.FloorDiv: "//",
    ast.Mod: "%",
    ast.Pow: "^",
}
class InvalidDomainError(ValueError):
    """Domain can contain only '!', '&', '|', tuples or expression whose returns boolean"""

#######################################################################


def convert_template_modifiers(env, arch, root, rec_model, view_type, ref, inherited_root=None):
    """Convert old syntax (attrs, states...) into new modifiers syntax"""
    result = arch
    if not arch.startswith('<data>'):
        raise ValueError(f'Wrong formating for view conversion. Arch must be wrapped with <data>: {ref!r}\n{arch}')

    if inherited_root is None:  # this is why it must be False
        result = convert_basic_view(arch, root, env, rec_model, view_type, ref)
    else:
        result = convert_inherit_view(arch, root, env, rec_model, view_type, ref, inherited_root)

    if not result.startswith('<data>'):
        raise ValueError(f'View conversion failed. Result should had been wrapped with <data>: {ref!r}\n{result}')

    root_result = etree.fromstring(result.encode())

    # Check for incomplete conversion, those attributes should had been removed by
    # convert_basic_view and convert_inherit_view. In case there are some left
    # just log an error but keep the converted view in the database/file.
    for item in root_result.findall('.//attribute[@name="states"]'):
        xml = etree.tostring(item, encoding='unicode')
        _logger.error('Incomplete view conversion ("states"): %r\n%s', ref, xml)
    for item in root_result.findall('.//attribute[@name="attrs"]'):
        xml = etree.tostring(item, encoding='unicode')
        _logger.error('Incomplete view conversion ("attrs"): %r\n%s', ref, xml)
    for item in root_result.findall('.//*[@attrs]'):
        xml = etree.tostring(item, encoding='unicode')
        _logger.error('Incomplete view conversion ("attrs"): %r\n%s', ref, xml)
    for item in root_result.findall('.//*[@states]'):
        xml = etree.tostring(item, encoding='unicode')
        _logger.error('Incomplete view conversion ("states"): %r\n%s', ref, xml)

    return result

def convert_basic_view(arch, root, env, model, view_type, ref):
    updated_nodes, _analysed_nodes = convert_node_modifiers_inplace(root, env, model, view_type, ref)
    if not updated_nodes:
        return arch
    return replace_and_keep_indent(root, arch, ref)

def convert_inherit_view(arch, root, env, model, view_type, ref, inherited_root):
    updated = False
    result = arch

    def get_target(spec):
        target_node = None

        try:
            with mute_logger("odoo.tools.template_inheritance"):
                target_node = locate_node(inherited_root, spec)
                # target can be None without error
        except Exception:
            pass

        if target_node is None:
            clone = etree.tostring(etree.Element(spec.tag, spec.attrib), encoding='unicode')
            _logger.info('Target not found for %s with xpath: %s', ref, clone)
            return None, view_type, model

        parent_view_type = view_type
        target_model = model
        parent_f_names = []
        for p in target_node.iterancestors():
            if p.tag == 'field' or p.tag == 'groupby':  # subview and groupby in tree view
                parent_f_names.append(p.get('name'))

        for p in target_node.iterancestors():
            if p.tag in ('groupby', 'header'):
                # in tree view
                parent_view_type = 'form'
                break
            elif p.tag in ('tree', 'form', 'setting'):
                parent_view_type = p.tag
                break

        for name in reversed(parent_f_names):
            try:
                field = target_model._fields[name]
                target_model = env[field.comodel_name]
            except KeyError:
                # Model is custom or had been removed. Can convert view without using field python states
                if name in target_model._fields:
                    _logger.warning("Unknown model %s. The <field> modifiers may be incompletely converted. %s", target_model._fields[name].comodel_name, ref)
                else:
                    _logger.warning("Unknown field %s on model %s. The <field> modifiers may be incompletely converted. %s", name, target_model, ref)
                target_model = None
                break

        return target_node, parent_view_type, target_model

    specs = []
    for spec in root:
        if isinstance(spec.tag, str):
            if spec.tag == 'data':
                specs.extend(c for c in spec)
            else:
                specs.append(spec)

    for spec in specs:
        spec_xml = get_targeted_xml_content(spec, result)

        if spec.get('position') == 'attributes':
            target_node, parent_view_type, target_model = get_target(spec)
            updated = convert_inherit_attributes_inplace(spec, target_node, parent_view_type)
            xml = etree.tostring(spec, pretty_print=True, encoding='unicode').replace('&quot;', "'").strip()
        else:
            _target_node, parent_view_type, target_model = get_target(spec)
            updated = convert_node_modifiers_inplace(spec, env, target_model, parent_view_type, ref)[0] or updated
            xml = replace_and_keep_indent(spec, spec_xml, ref)
        try:
            with mute_logger("odoo.tools.template_inheritance"):
                inherited_root = apply_inheritance_specs(inherited_root, etree.fromstring(xml))
        except (ValueError, etree.XPathSyntaxError, ValidationError):
            clone = xml.split('>', 1)[0] + '>'
            if '%(' in clone:
                _logger.info('Can not apply inheritance: %s\nPath: %r', ref, clone )
            else:
                _logger.error('Can not apply inheritance: %s\nPath: %r', ref, clone )
                # updated = True
                # xml = xml.replace('--', '- -').replace('--', '- -')
                # comment = etree.Comment(f' {xml} ')
                # spec.getparent().replace(spec, comment)
                # xml = f'<!-- {xml} -->'
        except Exception as e:
            _logger.error('Can not apply inheritance: %s\nPath: %r', ref, xml.split('>', 1)[0] + '>' )
            # updated = True
            # xml = xml.replace('--', '- -').replace('--', '- -')
            # comment = etree.Comment(f' {xml} ')
            # spec.getparent().replace(spec, comment)
            # xml = f'<!-- {xml} -->'

        if updated:
            if spec_xml not in result:
                _logger.error('Can not apply inheritance: %s\nPath: %r', ref, xml.split('>', 1)[0] + '>' )
            else:
                result = result.replace(spec_xml, xml, 1)

    return result

def convert_inherit_attributes_inplace(spec, target_node, view_type):
    """
    convert inherit with <attribute name="attrs"> + <attribute name="invisible">
    The conversion is different if attrs and invisible/readonly/required are modified.
    (can replace attributes, or use separator " or " to combine with previous)

    migration is idempotent, this eg stay unchanged:
        <attribute name="invisible">(aaa)</invisible>
        <attribute name="invisible">0</attribute>
        <attribute name="invisible">1</attribute>
        <attribute name="invisible" add="context.get('aaa')" separator=" or "/>
    """

    migrated = False
    has_change = False
    items = {}
    to_remove = set()
    node = None
    for attr in ('attrs', 'column_invisible', 'invisible', 'readonly', 'required'):
        nnode = spec.find(f'.//attribute[@name="{attr}"]')
        if nnode is None:
            continue
        to_remove.add(nnode)

        value = nnode.text and nnode.text.strip()
        if value not in ('True', 'False', '0', '1'):
            node = nnode
        if nnode.get('separator') or (value and value[0] == '('):
            # previously migrate
            migrated = True
            break
        if attr == 'attrs':
            try:
                value = value and ast.literal_eval(value) or {'invisible': '', 'readonly': '', 'required': ''}
            except Exception as error:
                raise ValueError(f'Can not convert "attrs": {value!r}') from error
        elif (attr == 'invisible' and view_type == 'tree'
              and (value in ('0', '1', 'True', 'False')
                   or (value.startswith('context') and ' or ' not in value and ' and ' not in value))):
            attr = 'column_invisible'
        items[attr] = value

    if node is None or not items or migrated:
        return has_change

    index = spec.index(node)
    is_last = spec[-1] == node

    domain_attrs = items.pop('attrs', {})
    all_attrs = list((set(items) | set(domain_attrs)))
    all_attrs.sort()

    i = len(all_attrs)
    next_xml = ''

    for attr in all_attrs:
        value = items.get(attr)
        domain = domain_attrs.get(attr, '')
        attr_value = domain_to_expression(domain) if isinstance(domain, list) else str(domain)

        i -= 1
        elem = etree.Element('attribute', {'name': attr})
        if i or not is_last:
            elem.tail = spec.text
        else:
            elem.tail = spec[-1].tail
            spec[-1].tail = spec.text

        if value and attr_value:
            has_change = True
            # replace whole expression
            if value in ('False', '0'):
                elem.text = attr_value
            elif value in ('True', '1'):
                elem.text = value
            else:
                elem.text = f'({value}) or ({attr_value})'
        else:
            inherited_value = target_node.get(attr) if target_node is not None else None
            inherited_context = _get_expression_contextual_values(ast.parse(inherited_value.strip(), mode='eval').body) if inherited_value else set()
            res_value = value or attr_value or 'False'

            if inherited_context:
                # replace whole expression if replace record value by record value, or context/parent by context/parent
                # <field invisible="context.get('a')"/>
                # is replaced
                #
                # <field attrs="{'invisible': [('b', '=', 1)]}"/> => <field invisible="b == 1"/>
                # will be combined
                #
                # <field invisible="context.get('a')" attrs="{'invisible': [('b', '=', 1)]}"/>  => <field invisible="context.get('a') or b == 1"/>
                # logged because human control is necessary

                context = _get_expression_contextual_values(ast.parse(res_value.strip(), mode='eval').body)

                has_record = any(True for v in context if not v.startswith('context.'))
                has_context = any(True for v in context if v.startswith('context.'))
                inherited_has_record = any(True for v in inherited_context if not v.startswith('context.'))
                inherited_has_context = any(True for v in inherited_context if v.startswith('context.'))

                if has_record == inherited_has_record and has_context == inherited_has_context:
                    elem.text = res_value
                    if attr_value:
                        has_change = True
                elif has_context and not has_record:
                    elem.set('add', res_value)
                    elem.set('separator', ' or ')
                    has_change = True
                elif not inherited_has_record:
                    elem.set('add', res_value)
                    elem.set('separator', ' or ')
                    has_change = True
                elif not value and not attr_value:
                    has_change = True
                elif res_value in ('0', 'False', '1', 'True'):
                    elem.text = res_value
                    has_change = True
                else:
                    elem.set('add', res_value)
                    elem.set('separator', ' or ')
                    has_change = True
                    _logger.info('The migration of attributes inheritance might not be exact: %s', etree.tostring(elem, encoding="unicode"))
            elif not value and not attr_value:
                continue
            else:
                elem.text = res_value
                if attr_value:
                    has_change = True

        spec.insert(index, elem)
        index += 1

    # remove previous node and xml
    for node in to_remove:
        spec.remove(node)

    return has_change

def convert_node_modifiers_inplace(root, env, model, view_type, ref):
    """Convert inplace old syntax (attrs, states...) into new modifiers syntax"""
    updated_nodes = set()
    analysed_nodes = set()

    def expr_to_attr(item, py_field_modifiers=None, field=None):
        if item in analysed_nodes:
            return
        analysed_nodes.add(item)

        try:
            modifiers = extract_node_modifiers(item, view_type, py_field_modifiers)
        except ValueError as error:
            if ('country_id != %(base.' in error.args[0] or
                '%(base.lu)d not in account_enabled_tax_country_ids' in error.args[0]):
                # Odoo xml file can use %(...)s ref/xmlid, this part is
                # replaced later by the record id. This code cannot be
                # parsed into a domain and convert into a expression.
                # Just skip it.
                return
            xml = etree.tostring(item, encoding='unicode')
            _logger.error("Invalid modifiers syntax: %s\nError: %s\n%s", ref, error, xml)
            return

        # apply new modifiers on item only when modified...
        for attr in ('column_invisible', 'invisible', 'readonly', 'required'):
            new_py_expr = modifiers.pop(attr, None)
            old_expr = item.attrib.get(attr)

            if (  old_expr == new_py_expr
               or (old_expr in ('1', 'True') and new_py_expr == 'True')
               or (old_expr in ('0', 'False') and new_py_expr in ('False', None))):
                continue

            if new_py_expr and (new_py_expr != 'False'
                or (attr == 'readonly' and field and field.readonly)
                or (attr == 'required' and field and field.required)):
                item.attrib[attr] = new_py_expr
            else:
                item.attrib.pop(attr, None)
            updated_nodes.add(item)

        # ... and remove old attributes
        if item.attrib.pop('states', None):
            updated_nodes.add(item)
        if item.attrib.pop('attrs', None):
            updated_nodes.add(item)

        # they are some modifiers left, some templates are badly storing
        # options in attrs, then they must be left as is (e.g.: studio
        # widget, name, ...)
        if modifiers:
            item.attrib['attrs'] = repr(modifiers)

    def in_subview(item):
        for p in item.iterancestors():
            if p == root:
                return False
            if p.tag in ('field', 'groupby'):
                return True

    if model is not None:
        if view_type == 'tree':
            # groupby from tree target the field as a subview (inside groupby is treated as form)
            for item in root.findall('.//groupby[@name]'):
                f_name = item.get('name')
                field = model._fields[f_name]
                updated, fnodes =  convert_node_modifiers_inplace(item, env, env[field.comodel_name], 'form', ref)
                analysed_nodes.update(fnodes)
                updated_nodes.update(updated)

        for item in root.findall('.//field[@name]'):
            if in_subview(item):
                continue

            if item in analysed_nodes:
                continue

            # in kanban view, field outside the template should not have modifiers
            if view_type == 'kanban' and item.getparent().tag == 'kanban':
                for attr in ('states', 'attrs', 'column_invisible', 'invisible', 'readonly', 'required'):
                    item.attrib.pop(attr, None)
                continue

            # shortcut for views that do not use information from the python field
            if view_type not in ('kanban', 'tree', 'form', 'setting'):
                expr_to_attr(item)
                continue

            f_name = item.get('name')

            if f_name not in model._fields:
                _logger.warning("Unknown field %r from %r, can not migrate 'states' python field attribute in view %s", f_name, model._name, ref)
                continue

            field = model._fields[f_name]

            # get subviews
            if field.comodel_name:
                for subview in item.getchildren():
                    subview_type = subview.tag if subview.tag != 'groupby' else 'form'
                    updated, fnodes = convert_node_modifiers_inplace(subview, env, env[field.comodel_name], subview_type, ref)
                    analysed_nodes.update(fnodes)
                    updated_nodes.update(updated)

            # use python field to convert view <field>
            if item.get('readonly'):
                expr_to_attr(item, field=field)
            elif field.states:
                readonly = bool(field.readonly)
                fnames = [k for k, v in field.states.items() if v[0][1] != readonly]
                if fnames:
                    fnames.sort()
                    dom = [('state', 'not in' if readonly else 'in', fnames)]
                    expr_to_attr(item, py_field_modifiers={'readonly': domain_to_expression(dom)}, field=field)
                else:
                    expr_to_attr(item)
            elif field.readonly not in (True, False):
                try:
                    readonly_expr = domain_to_expression(str(field.readonly))
                except ValueError:
                    _logger.warning("Can not convert readonly: %r", field.readonly)
                    continue
                if readonly_expr in ('0', '1'):
                    readonly_expr = str(readonly_expr == '1')
                expr_to_attr(item, py_field_modifiers={'readonly': readonly_expr}, field=field)
            else:
                expr_to_attr(item, field=field)

    # processes all elements that have not been converted
    for item in unique(itertools.chain(
            root.findall('.//*[@attrs]'),
            root.findall('.//*[@states]'),
            root.findall('.//tree/*[@invisible]'))):
        expr_to_attr(item)

    return updated_nodes, analysed_nodes

reg_comment = r'<!--(?:-(?!-)|\n|[^-])+-->'
reg_att1 = r'[a-zA-Z0-9._-]+\s*=\s*"(?:\n|[^"])*"'
reg_att2 = r"[a-zA-Z0-9._-]+\s*=\s*'(?:\n|[^'])*'"
reg_open_tag = rf'''<[a-zA-Z0-9]+(?:\s*\n|\s+{reg_att1}|\s+{reg_att2})*\s*/?>'''
reg_close_tag = r'</[a-zA-Z0-9]+\s*>'
reg_split = rf'((?:\n|[^<])*)({reg_comment}|{reg_open_tag}|{reg_close_tag})((?:\n|[^<])*)'
reg_attrs = r''' (attrs|states|invisible|column_invisible|readonly|required)=("(?:\n|[^"])*"|'(?:\n|[^'])*')'''
close_placeholder = '</XXXYXXX>'
def split_xml(arch):
    """ split xml in tags, add a close tag for each void. """
    split = list(re.findall(reg_split, arch.replace('/>', f'/>{close_placeholder}')))
    return split

def get_targeted_xml_content(spec, field_arch_content):
    spec_xml  = etree.tostring(spec, encoding='unicode').strip()
    if spec_xml in field_arch_content:
        return spec_xml

    for ancestor in spec.iterancestors():
        if ancestor.tag in ('field', 'data'):
            break

    spec_index = ancestor.index(spec)

    xml = ''
    level = 0
    index = 0
    for before, tag, after in split_xml(field_arch_content):
        if index - 1 == spec_index:
            xml += before + tag + after
        if tag[1] == '/':
            level -= 1
        elif tag[1] != '!':
            level += 1
        if level == 1:
            index += 1

    if not xml:
        ValueError('Source inheritance spec not found for %s: %s', ref, spec_xml)

    return xml.replace(close_placeholder, '').strip()

def replace_and_keep_indent(element, arch, ref):
    """ Generate micro-diff from updated attributes """
    next_record = etree.tostring(element, encoding='unicode').replace("&quot;", "'").strip()
    n_split = split_xml(next_record)
    arch = arch.strip()
    p_split = split_xml(arch)

    control = ''
    level = 0
    for i in range(max(len(p_split), len(n_split))):
        p_node = p_split[i][1]
        n_node = n_split[i][1]
        control += ''.join(p_split[i])

        if p_node[1] != '/' and p_node[1] != '!':
            level += 1

        replace_by = p_node
        if p_node != n_node:
            if p_node == close_placeholder and not n_node.startswith('</'):
                raise ValueError("Wrong split for convertion in %s\n\n---------\nSource node:  None\nCurrent node:  %s\nSource arch:  %s\nCurrent arch:  %s" % (
                    ref, n_node, arch, next_record))
            if n_node == close_placeholder and not p_node.startswith('</'):
                raise ValueError("Wrong split for convertion in %s\n\n---------\nSource node:  %s\nCurrent node:  None\nSource arch:  %s\nCurrent arch:  %s" % (
                    ref, p_node, arch, next_record))

            p_tag = re.split(r'[<>\n /]+', p_node, 2)[1]
            n_tag = re.split(r'[<>\n /]+', n_node, 2)[1]
            if p_node != close_placeholder and n_node != close_placeholder and p_tag != n_tag:
                raise ValueError("Wrong split for convertion in %s\n\n---------\nSource node:  %s\nCurrent node:  %s\nSource arch:  %s\nCurrent arch:  %s" % (
                    ref, p_node, n_node, arch, next_record))

            p_attrs = {k: v[1:-1] for k, v in re.findall(reg_attrs, p_node)}
            n_attrs = {k: v[1:-1] for k, v in re.findall(reg_attrs, n_node)}

            if p_attrs != n_attrs:
                if p_attrs:
                    key, value = p_attrs.popitem()
                    for j in p_attrs:
                        replace_by = replace_by.replace(f' {j}="{p_attrs[j]}"', '')
                    rep = ''
                    if n_attrs:
                        space = re.search(rf'(\n? +){key}=', replace_by).group(1)
                        rep = ' ' + space.join(f'{k}="{v}"' for k, v in n_attrs.items())
                    replace_by = re.sub(r""" %s=["']%s["']""" % (re.escape(key), re.escape(value)), rep, replace_by)
                    replace_by = re.sub('(?: *\n +)+(\n +)', r'\1', replace_by)
                    replace_by = re.sub('(?: *\n +)(/?>)', r'\1', replace_by)
                else:
                    rep = ''
                    if n_attrs:
                        rep = ' ' + ' '.join(f'{k}="{v}"' for k, v in n_attrs.items())
                    if p_node.endswith('/>'):
                        replace_by = replace_by[0:-2] + rep + '/>'
                    else:
                        replace_by = replace_by[0:-1] + rep + '>'

        if p_node[1] == '/':
            level -= 1

        p_split[i] = (p_split[i][0], replace_by, p_split[i][2])

    xml = ''.join(''.join(s) for s in p_split).replace(f'/>{close_placeholder}', '/>')

    control = control.replace(f'/>{close_placeholder}', '/>')

    if not control or level != 0:
        _logger.error("Wrong convertion in %s\n\n%s", ref, control)
        raise ValueError('Missing update: \n{control}')

    return xml

def extract_node_modifiers(node, view_type, py_field_modifiers=None):
    """extract the node modifiers and concat attributes (attrs, states...)"""

    modifiers = {}

    # modifiers from deprecated attrs
    # <field attrs="{'invisible': &quot;[['user_id', '=', uid]]&quot;, 'readonly': [('name', '=', 'toto')]}" .../>
    # =>
    # modfiers['invisible'] = 'user_id == uid'
    # modfiers['readonly'] = 'name == "toto"'
    attrs = ast.literal_eval(node.attrib.get("attrs", "{}")) or {}
    for modifier, val in attrs.items():
        try:
            domain = modifier_to_domain(val)
            py_expression = domain_to_expression(domain)
        except Exception as error:
            raise ValueError(f"Invalid modifier {modifier!r}: {val!r}\n{error}") from error
        modifiers[modifier] = py_expression

    # invisible modifier from deprecated states
    # <field states="draft,done" .../>
    # =>
    # modifiers['invisible'] = "state not in ('draft', 'done')"
    states = node.attrib.get('states')
    if states:
        value = tuple(states.split(","))
        if len(value) == 1:
            py_expression = f'state != {value[0]!r}'
        else:
            py_expression = f'state not in {value!r}'
        invisible = modifiers.get('invisible') or 'False'
        if invisible == 'False':
            modifiers['invisible'] = py_expression
        else:
            # only add parenthesis if necessary
            if ' and ' in py_expression or ' or ' in py_expression:
                py_expression = f'({py_expression})'
            if ' and ' in invisible or ' or ' in invisible:
                invisible = f'({invisible})'
            modifiers['invisible'] = f'{invisible} and {py_expression}'

    # extract remaining modifiers
    # <field invisible="context.get('hide')" .../>
    for modifier in ('column_invisible', 'invisible', 'readonly', 'required'):
        py_expression = node.attrib.get(modifier, '').strip()
        if not py_expression:
            if modifier not in modifiers and py_field_modifiers and py_field_modifiers.get(modifier):
                modifiers[modifier] = py_field_modifiers[modifier]
            continue

        try:
            # most (~95%) elements are 1/True/0/False
            py_expression = repr(str2bool(py_expression))
        except ValueError:
            # otherwise, make sure it is a valid expression
            try:
                modifier_ast = ast.parse(f'({py_expression})', mode='eval').body
                py_expression = repr(_modifier_to_domain_ast_leaf(modifier_ast))
            except Exception as error:
                raise ValueError(f'Invalid modifier {modifier!r}: {error}: {py_expression!r}') from None

        # Special case, must rename "invisible" to "column_invisible"
        if modifier == 'invisible' and py_expression != 'False' and not get_expression_field_names(py_expression):
            parent_view_type = view_type
            for parent in node.iterancestors():
                if parent.tag in ('tree', 'form', 'setting', 'kanban', 'calendar', 'search'):
                    parent_view_type = parent.tag
                    break
                if parent.tag in ('groupby', 'header'):  # tree view element with form view behavior
                    parent_view_type = 'form'
                    break
            if parent_view_type == 'tree':
                modifier = 'column_invisible'

        # previous_py_expr and py_expression must be OR-ed
        # first 3 cases are short circuits
        previous_py_expr = modifiers.get(modifier, 'False')
        if (previous_py_expr == 'True'               # True or ... => True
            or py_expression == 'True'):             # ... or True => True
            modifiers[modifier] = 'True'
        elif previous_py_expr == 'False':            # False or ... => ...
            modifiers[modifier] = py_expression
        elif py_expression == 'False':               # ... or False => ...
            modifiers[modifier] = previous_py_expr
        else:
            # only add parenthesis if necessary
            if ' and ' in previous_py_expr or ' or ' in previous_py_expr:
                previous_py_expr = f'({previous_py_expr})'
            modifiers[modifier] = f'{py_expression} or {previous_py_expr}'

    return modifiers

def domain_to_expression(domain):
    """Convert the given domain into a python expression"""
    domain = normalize_domain(domain)
    domain = distribute_not(domain)
    operators = []
    expression = []
    for leaf in reversed(domain):
        if leaf == AND_OPERATOR:
            right = expression.pop()
            if operators.pop() == OR_OPERATOR:
                right = f'({right})'
            left = expression.pop()
            if operators.pop() == OR_OPERATOR:
                left = f'({left})'
            expression.append(f'{right} and {left}')
            operators.append(leaf)
        elif leaf == OR_OPERATOR:
            right = expression.pop()
            operators.pop()
            left = expression.pop()
            operators.pop()
            expression.append(f'{right} or {left}')
            operators.append(leaf)
        elif leaf == NOT_OPERATOR:
            expr = expression.pop()
            operators.pop()
            expression.append(f'not ({expr})')
            operators.append(leaf)
        elif leaf is True or leaf is False:
            expression.append(repr(leaf))
            operators.append(None)
        elif isinstance(leaf, (tuple, list)):
            left, op, right = leaf
            if left == 1:  # from TRUE_LEAF
                expr = 'True'
            elif left == 0:  # from FALSE_LEAF
                expr = 'False'
            elif isinstance(left, ContextDependentDomainItem):
                # from expression to use TRUE_LEAF or FALSE_LEAF
                expr = repr(left)
            elif op == '=' or op == '==':
                if right is False or right == []:
                    expr = f'not {left}'
                elif left.endswith('_ids'):
                    expr = f'{right!r} in {left}'
                elif right is True:
                    expr = f'{left}'
                elif right is False:
                    expr = f'not {left}'
                else:
                    expr = f'{left} == {right!r}'
            elif op == '!=' or op == '<>':
                if right is False or right == []:
                    expr = str(left)
                elif left.endswith('_ids'):
                    expr = f'{right!r} not in {left}'
                elif right is True:
                    expr = f'not {left}'
                elif right is False:
                    expr = f'{left}'
                else:
                    expr = f'{left} != {right!r}'
            elif op in ('<=', '<', '>', '>='):
                expr = f'{left} {op} {right!r}'
            elif op == '=?':
                expr = f'(not {right} or {left} in {right!r})'
            elif op == 'in' or op == 'not in':
                right_str = str(right)
                if right_str == '[None, False]':
                    expr = f'not ({left})'
                elif left.endswith('_ids'):
                    if right_str.startswith('[') and ',' not in right_str:
                        expr = f'{right[0]!r} {op} {left}'
                    if not right_str.startswith('[') and right_str.endswith('id'):
                        # fix wrong use of 'in' inside domain
                        expr = f'{right_str!r} {op} {left}'
                    else:
                        raise ValueError(f"Can not convert {domain!r} to python expression")
                else:
                    if right_str.startswith('[') and ',' not in right_str:
                        op = '==' if op == 'in' else '!='
                        expr = f'{left} {op} {right[0]!r}'
                    else:
                        expr = f'{left} {op} {right!r}'
            elif op == 'like' or op == 'not like':
                if isinstance(right, str):
                    part = right.split('%')
                    if len(part) == 1:
                        op = 'in' if op == 'like' else 'not in'
                        expr = f'{right!r} {op} ({left} or "")'
                    elif len(part) == 2:
                        if part[0] and part[1]:
                            expr = f'({left} or "").startswith({part[0]!r}) and ({left} or "").endswith({part[1]!r})'
                        elif part[0]:
                            expr = f'({left} or "").startswith({part[0]!r})'
                        elif part[1]:
                            expr = f'({left} or "").endswith({part[0]!r})'
                        else:
                            expr = str(left)
                        if op.startswith('not '):
                            expr = f'not ({expr})'
                    else:
                        raise ValueError(f"Can not convert {domain!r} to python expression")
                else:
                    op = 'in' if op == 'like' else 'not in'
                    expr = f'{right!r} {op} ({left} or "")'
            elif op == 'ilike' or op == 'not ilike':
                if isinstance(right, str):
                    part = right.split('%')
                    if len(part) == 1:
                        op = 'in' if op == 'ilike' else 'not in'
                        expr = f'{right!r}.lower() {op} ({left} or "").lower()'
                    elif len(part) == 2:
                        if part[0] and part[1]:
                            expr = f'({left} or "").lower().startswith({part[0]!r}) and ({left} or "").lower().endswith({part[1]!r})'
                        elif part[0]:
                            expr = f'({left} or "").lower().startswith({part[0]!r})'
                        elif part[1]:
                            expr = f'({left} or "").lower().endswith({part[0]!r})'
                        else:
                            expr = str(left)
                        if op.startswith('not '):
                            expr = f'not ({expr})'
                    else:
                        raise ValueError(f"Can not convert {domain!r} to python expression")
                else:
                    op = 'in' if op == 'like' else 'not in'
                    expr = f'{right!r}.lower() {op} ({left} or "").lower()'
            else:
                raise ValueError(f"Can not convert {domain!r} to python expression")
            expression.append(expr)
            operators.append(None)
        else:
            expression.append(repr(leaf))
            operators.append(None)

    return expression.pop()

class ContextDependentDomainItem():
    def __init__(self, value, names, returns_boolean=False, returns_domain=False):
        self.value = value
        self.contextual_values = names
        self.returns_boolean = returns_boolean
        self.returns_domain = returns_domain
    def __str__(self):
        if self.returns_domain:
            return repr(self.value)
        return self.value
    def __repr__(self):
        return self.__str__()

def _modifier_to_domain_ast_wrap_domain(modifier_ast):
    try:
        domain_item = _modifier_to_domain_ast_leaf(modifier_ast, should_contain_domain=True)
    except Exception as e:
        raise ValueError(f'{e}\nExpression must returning a valid domain in all cases') from None

    if not isinstance(domain_item, ContextDependentDomainItem) or not domain_item.returns_domain:
        raise ValueError('Expression must returning a valid domain in all cases')
    return domain_item.value

def _modifier_to_domain_ast_domain(modifier_ast):
    # ['|', ('a', '=', 'b'), ('user_id', '=', uid)]

    if not isinstance(modifier_ast, ast.List):
        raise ValueError('This part must be a domain') from None

    domain = []
    for leaf in modifier_ast.elts:
        if isinstance(leaf, ast.Str) and leaf.s in DOMAIN_OPERATORS:
            # !, |, &
            domain.append(leaf.s)
        elif isinstance(leaf, ast.Constant):
            if leaf.value is True or leaf.value is False:
                domain.append(leaf.value)
            else:
                raise InvalidDomainError()
        elif isinstance(leaf, (ast.List, ast.Tuple)):
            # domain tuple
            if len(leaf.elts) != 3:
                raise InvalidDomainError()
            elif not isinstance(leaf.elts[0], ast.Constant) and not (isinstance(leaf.elts[2], ast.Constant) and leaf.elts[2].value == 1):
                raise InvalidDomainError()
            elif not isinstance(leaf.elts[1], ast.Constant):
                raise InvalidDomainError()

            left_ast, operator_ast, right_ast = leaf.elts

            operator = operator_ast.value
            if operator == '==':
                operator = '='
            elif operator == '<>':
                operator = '!='
            elif operator not in TERM_OPERATORS:
                raise InvalidDomainError()

            left = _modifier_to_domain_ast_leaf(left_ast)
            right = _modifier_to_domain_ast_leaf(right_ast)
            domain.append((left, operator, right))
        else:
            item = _modifier_to_domain_ast_leaf(leaf)
            domain.append(item)
            if item not in (True, False) and isinstance(item, ContextDependentDomainItem) and not item.returns_boolean:
                raise InvalidDomainError()

    return normalize_domain(domain)

def _modifier_to_domain_ast_leaf(item_ast, should_contain_domain=False, need_parenthesis=False):
    # [('a', '=', True)]
    # True
    if isinstance(item_ast, ast.Constant):
        return item_ast.value

    # [('a', '=', 'b')]
    # 'b'
    if isinstance(item_ast, ast.Str):
        return item_ast.s

    # [('a', '=', 1)] if context.get('b') else []
    # [('a', '=', 1)]
    if should_contain_domain and isinstance(item_ast, ast.List):
        domain = _modifier_to_domain_ast_domain(item_ast)
        _fnames, vnames = get_domain_value_names(domain)
        return ContextDependentDomainItem(domain, vnames, returns_domain=True)

    # [('obj_ids', 'in', [uid or False, 33])]
    # [uid or False, 33]
    if isinstance(item_ast, (ast.List, ast.Tuple)):
        vnames = set()
        values = []
        for item in item_ast.elts:
            value = _modifier_to_domain_ast_leaf(item)
            if isinstance(value, ContextDependentDomainItem):
                vnames.update(value.contextual_values)
            values.append(value)

        if isinstance(item_ast, ast.Tuple):
            values = tuple(values)

        if vnames:
            return ContextDependentDomainItem(repr(values), vnames)
        else:
            return values

    # [('a', '=', uid)]
    # uid
    if isinstance(item_ast, ast.Name):
        vnames = {item_ast.id}
        return ContextDependentDomainItem(item_ast.id, vnames)

    # [('a', '=', parent.b)]
    # parent.b
    if isinstance(item_ast, ast.Attribute):
        vnames = set()
        name = _modifier_to_domain_ast_leaf(item_ast.value, need_parenthesis=True)
        if isinstance(name, ContextDependentDomainItem):
            vnames.update(name.contextual_values)
        value = f"{name!r}.{item_ast.attr}"
        if value.startswith('parent.'):
            vnames.add(value)
        return ContextDependentDomainItem(value, vnames)

    # [('a', '=', company_ids[1])]
    # [1]
    if isinstance(item_ast, ast.Index): # deprecated python ast class for Subscript key
        return _modifier_to_domain_ast_leaf(item_ast.value)

    # [('a', '=', company_ids[1])]
    # [1]
    if isinstance(item_ast, ast.Subscript):
        vnames = set()
        name = _modifier_to_domain_ast_leaf(item_ast.value, need_parenthesis=True)
        if isinstance(name, ContextDependentDomainItem):
            vnames.update(name.contextual_values)

        key = _modifier_to_domain_ast_leaf(item_ast.slice)
        if isinstance(key, ContextDependentDomainItem):
            vnames.update(key.contextual_values)
        value = f"{name!r}[{key!r}]"

        return ContextDependentDomainItem(value, vnames)

    # [('a', '=', context.get('abc', 'default') == 'b')]
    # ==
    if isinstance(item_ast, ast.Compare):
        vnames = set()

        if len(item_ast.ops) > 1:
            raise ValueError(f"Should not more than one comparaison: {expr}")

        left = _modifier_to_domain_ast_leaf(item_ast.left, need_parenthesis=True)
        if isinstance(left, ContextDependentDomainItem):
            vnames.update(left.contextual_values)

        operator = AST_OP_TO_STR[type(item_ast.ops[0])]

        right = _modifier_to_domain_ast_leaf(item_ast.comparators[0], need_parenthesis=True)
        if isinstance(right, ContextDependentDomainItem):
            vnames.update(right.contextual_values)

        expr = f"{left!r} {operator} {right!r}"
        return ContextDependentDomainItem(expr, vnames, returns_boolean=True)

    # [('a', '=', 1 - 3]
    # 1 - 3
    if isinstance(item_ast, ast.BinOp):
        vnames = set()

        left = _modifier_to_domain_ast_leaf(item_ast.left)
        if isinstance(left, ContextDependentDomainItem):
            vnames.update(left.contextual_values)

        operator = AST_OP_TO_STR[type(item_ast)]

        right = _modifier_to_domain_ast_leaf(item_ast.right)
        if isinstance(right, ContextDependentDomainItem):
            vnames.update(right.contextual_values)

        expr = f"{left!r} {operator} {right!r}"
        return ContextDependentDomainItem(expr, vnames)

    # [(1, '=', field_name and 1 or 0]
    # field_name and 1
    if isinstance(item_ast, ast.BoolOp):
        vnames = set()

        returns_boolean = True
        returns_domain = False

        values = []
        for ast_value in item_ast.values:
            value = _modifier_to_domain_ast_leaf(ast_value, should_contain_domain, need_parenthesis=True)
            if isinstance(value, ContextDependentDomainItem):
                vnames.update(value.contextual_values)
                if not value.returns_boolean:
                    returns_boolean = False
                if value.returns_domain:
                    returns_domain = True
            elif not isinstance(value, bool):
                returns_boolean = False
            values.append(repr(value))

        if returns_domain:
            raise ValueError("Use if/else condition instead of boolean operator to return domain.")

        if isinstance(item_ast.op, ast.Or):
            expr = ' or '.join(values)
        else:
            expr = ' and '.join(values)
        if need_parenthesis and ' ' in expr:
            expr = f'({expr})'
        return ContextDependentDomainItem(expr, vnames, returns_boolean=returns_boolean)

    # [('a', '=', not context.get('abc', 'default')), ('a', '=', -1)]
    # not context.get('abc', 'default')
    if isinstance(item_ast, ast.UnaryOp):
        if isinstance(item_ast.operand, ast.Constant) and isinstance(item_ast.op, ast.USub) and isinstance(item_ast.operand.value, (int, float)):
            return -item_ast.operand.value

        leaf = _modifier_to_domain_ast_leaf(item_ast.operand, need_parenthesis=True)
        vnames = set()
        if isinstance(leaf, ContextDependentDomainItem):
            vnames.update(leaf.contextual_values)

        expr = f"not {leaf!r}"
        return ContextDependentDomainItem(expr, vnames, returns_boolean=True)

    # [('a', '=', int(context.get('abc', False))]
    # context.get('abc', False)
    if isinstance(item_ast, ast.Call):
        vnames = set()

        name = _modifier_to_domain_ast_leaf(item_ast.func, need_parenthesis=True)
        if isinstance(name, ContextDependentDomainItem) and name.value not in _BUILTINS:
            vnames.update(name.contextual_values)
        returns_boolean = str(name) == 'bool'

        values = []
        for arg in item_ast.args:
            value = _modifier_to_domain_ast_leaf(arg)
            if isinstance(value, ContextDependentDomainItem):
                vnames.update(value.contextual_values)
            values.append(repr(value))

        expr = f"{name!r}({', '.join(values)})"
        return ContextDependentDomainItem(expr, vnames, returns_boolean=returns_boolean)

    # [('a', '=', 1 if context.get('abc', 'default') == 'b' else 0)]
    # 1 if context.get('abc', 'default') == 'b' else 0
    if isinstance(item_ast, ast.IfExp):
        vnames = set()

        test = _modifier_to_domain_ast_leaf(item_ast.test)
        if isinstance(test, ContextDependentDomainItem):
            vnames.update(test.contextual_values)

        returns_boolean = True
        returns_domain = True

        body = _modifier_to_domain_ast_leaf(item_ast.body, should_contain_domain, need_parenthesis=True)
        if isinstance(body, ContextDependentDomainItem):
            vnames.update(body.contextual_values)
            if not body.returns_boolean:
                returns_boolean = False
            if not body.returns_domain:
                returns_domain = False
        else:
            returns_domain = False
            if not isinstance(body, bool):
                returns_boolean = False

        orelse = _modifier_to_domain_ast_leaf(item_ast.orelse, should_contain_domain, need_parenthesis=True)
        if isinstance(orelse, ContextDependentDomainItem):
            vnames.update(orelse.contextual_values)
            if not orelse.returns_boolean:
                returns_boolean = False
            if not orelse.returns_domain:
                returns_domain = False
        else:
            returns_domain = False
            if not isinstance(orelse, bool):
                returns_boolean = False

        if returns_domain:
            # [('id', '=', 42)] if parent.a else []
            not_test = ContextDependentDomainItem(f"not ({test})", vnames, returns_boolean=True)
            if not isinstance(test, ContextDependentDomainItem) or not test.returns_boolean:
                test = ContextDependentDomainItem(f"bool({test})", vnames, returns_boolean=True)
            # ['|', '&', bool(parent.a), ('id', '=', 42), not parent.a]
            expr = ['|', '&', test] + body.value + ['&', not_test] + orelse.value
        else:
            expr = f"{body!r} if {test} else {orelse!r}"

        return ContextDependentDomainItem(expr, vnames, returns_boolean=returns_boolean, returns_domain=returns_domain)

    if isinstance(item_ast, ast.Expr):
        return _modifier_to_domain_ast_leaf(item_ast.value)

    raise ValueError(f"Undefined item {item_ast!r}.")

def _modifier_to_domain_validation(domain):
    for leaf in domain:
        if leaf is True or leaf is False or leaf in DOMAIN_OPERATORS:
            continue
        try:
            left, operator, _right = leaf
        except ValueError:
            raise InvalidDomainError()
        except TypeError:
            if isinstance(leaf, ContextDependentDomainItem):
                if leaf.returns_boolean:
                    continue
                raise InvalidDomainError()
            raise InvalidDomainError()
        if leaf not in (TRUE_LEAF, FALSE_LEAF) and not isinstance(left, str):
            raise InvalidDomainError()
        if operator not in VALID_TERM_OPERATORS:
            raise InvalidDomainError()

def modifier_to_domain(modifier):
    """
    Convert modifier values to domain. Generated domains can contain
    contextual elements (right part of domain leaves). The domain can be
    concatenated with others using the `AND` and `OR` methods.
    The representation of the domain can be evaluated with the corresponding
    context.

    :params modifier (bool|0|1|domain|str|ast)
    :return a normalized domain (list(tuple|"&"|"|"|"!"|True|False))
    """
    from odoo.osv import expression

    if isinstance(modifier, bool):
        return [TRUE_LEAF if modifier else FALSE_LEAF]
    if isinstance(modifier, int):
        return [TRUE_LEAF if modifier else FALSE_LEAF]
    if isinstance(modifier, (list, tuple)):
        _modifier_to_domain_validation(modifier)
        return normalize_domain(modifier)
    if isinstance(modifier, ast.AST):
        try:
            return _modifier_to_domain_ast_domain(modifier)
        except Exception as e:
            raise ValueError(f'{e}: {modifier!r}') from None

    # modifier is a string
    modifier = modifier.strip()

    # most (~95%) elements are 1/True/0/False
    if modifier.lower() in ('0', 'false'):
        return [FALSE_LEAF]
    if modifier.lower() in ('1', 'true'):
        return [TRUE_LEAF]

    # [('a', '=', 'b')]
    try:
        domain = ast.literal_eval(modifier)
        _modifier_to_domain_validation(domain)
        return normalize_domain(domain)
    except SyntaxError:
        raise ValueError(f'Wrong domain python syntax: {modifier}')
    except ValueError:
        pass

    # [('a', '=', parent.b), ('a', '=', context.get('b'))]
    try:
        modifier_ast = ast.parse(f'({modifier})', mode='eval').body
        if isinstance(modifier_ast, ast.List):
            return _modifier_to_domain_ast_domain(modifier_ast)
        else:
            return _modifier_to_domain_ast_wrap_domain(modifier_ast)
    except Exception as e:
        raise ValueError(f'{e}: {modifier}')

def str2bool(s):
    s = s.lower()
    if s in ("1", "true"):
        return True
    if s in ("0", "false"):
        return False
    raise ValueError()

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。