244 lines
9.0 KiB
Python
244 lines
9.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
""" Modules migration handling. """
|
|
|
|
from collections import defaultdict
|
|
import glob
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
import re
|
|
from os.path import join as opj
|
|
|
|
import odoo.release as release
|
|
import odoo.upgrade
|
|
from odoo.tools.parse_version import parse_version
|
|
from odoo.tools.misc import file_path
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
VERSION_RE = re.compile(
|
|
r"""^
|
|
# Optional prefix with Odoo version
|
|
((
|
|
6\.1|
|
|
|
|
# "x.0" version, with x >= 6.
|
|
[6-9]\.0|
|
|
|
|
# multi digits "x.0" versions
|
|
[1-9]\d+\.0|
|
|
|
|
# x.saas~y, where x >= 7 and x <= 10
|
|
(7|8|9|10)\.saas~[1-9]\d*|
|
|
|
|
# saas~x.y, where x >= 11 and y between 1 and 9
|
|
# FIXME handle version >= saas~100 (expected in year 2106)
|
|
saas~(1[1-9]|[2-9]\d+)\.[1-9]
|
|
)\.)?
|
|
# After Odoo version we allow precisely 2 or 3 parts
|
|
# note this will also allow 0.0.0 which has a special meaning
|
|
\d+\.\d+(\.\d+)?
|
|
$""",
|
|
re.VERBOSE | re.ASCII,
|
|
)
|
|
|
|
|
|
def load_script(path, module_name):
|
|
full_path = file_path(path) if not os.path.isabs(path) else path
|
|
spec = importlib.util.spec_from_file_location(module_name, full_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class MigrationManager(object):
|
|
""" Manages the migration of modules.
|
|
|
|
Migrations files must be python files containing a ``migrate(cr, installed_version)``
|
|
function. These files must respect a directory tree structure: A 'migrations' folder
|
|
which contains a folder by version. Version can be 'module' version or 'server.module'
|
|
version (in this case, the files will only be processed by this version of the server).
|
|
Python file names must start by ``pre-`` or ``post-`` and will be executed, respectively,
|
|
before and after the module initialisation. ``end-`` scripts are run after all modules have
|
|
been updated.
|
|
|
|
A special folder named ``0.0.0`` can contain scripts that will be run on any version change.
|
|
In `pre` stage, ``0.0.0`` scripts are run first, while in ``post`` and ``end``, they are run last.
|
|
|
|
Example::
|
|
|
|
<moduledir>
|
|
`-- migrations
|
|
|-- 1.0
|
|
| |-- pre-update_table_x.py
|
|
| |-- pre-update_table_y.py
|
|
| |-- post-create_plop_records.py
|
|
| |-- end-cleanup.py
|
|
| `-- README.txt # not processed
|
|
|-- 9.0.1.1 # processed only on a 9.0 server
|
|
| |-- pre-delete_table_z.py
|
|
| `-- post-clean-data.py
|
|
|-- 0.0.0
|
|
| `-- end-invariants.py # processed on all version update
|
|
`-- foo.py # not processed
|
|
"""
|
|
|
|
def __init__(self, cr, graph):
|
|
self.cr = cr
|
|
self.graph = graph
|
|
self.migrations = defaultdict(dict)
|
|
self._get_files()
|
|
|
|
def _get_files(self):
|
|
def _get_upgrade_path(pkg):
|
|
for path in odoo.upgrade.__path__:
|
|
upgrade_path = opj(path, pkg)
|
|
if os.path.exists(upgrade_path):
|
|
yield upgrade_path
|
|
|
|
def _verify_upgrade_version(path, version):
|
|
full_path = opj(path, version)
|
|
if not os.path.isdir(full_path):
|
|
return False
|
|
|
|
if version == "tests":
|
|
return False
|
|
|
|
if not VERSION_RE.match(version):
|
|
_logger.warning("Invalid version for upgrade script %r", full_path)
|
|
return False
|
|
|
|
return True
|
|
|
|
def get_scripts(path):
|
|
if not path:
|
|
return {}
|
|
return {
|
|
version: glob.glob(opj(path, version, '*.py'))
|
|
for version in os.listdir(path)
|
|
if _verify_upgrade_version(path, version)
|
|
}
|
|
|
|
def check_path(path):
|
|
try:
|
|
return file_path(path)
|
|
except FileNotFoundError:
|
|
return False
|
|
|
|
for pkg in self.graph:
|
|
if not (hasattr(pkg, 'update') or pkg.state == 'to upgrade' or
|
|
getattr(pkg, 'load_state', None) == 'to upgrade'):
|
|
continue
|
|
|
|
|
|
self.migrations[pkg.name] = {
|
|
'module': get_scripts(check_path(pkg.name + '/migrations')),
|
|
'module_upgrades': get_scripts(check_path(pkg.name + '/upgrades')),
|
|
}
|
|
|
|
scripts = defaultdict(list)
|
|
for p in _get_upgrade_path(pkg.name):
|
|
for v, s in get_scripts(p).items():
|
|
scripts[v].extend(s)
|
|
self.migrations[pkg.name]["upgrade"] = scripts
|
|
|
|
def migrate_module(self, pkg, stage):
|
|
assert stage in ('pre', 'post', 'end')
|
|
stageformat = {
|
|
'pre': '[>%s]',
|
|
'post': '[%s>]',
|
|
'end': '[$%s]',
|
|
}
|
|
state = pkg.state if stage in ('pre', 'post') else getattr(pkg, 'load_state', None)
|
|
|
|
if not (hasattr(pkg, 'update') or state == 'to upgrade') or state == 'to install':
|
|
return
|
|
|
|
def convert_version(version):
|
|
if version == "0.0.0":
|
|
return version
|
|
if version.count(".") > 2:
|
|
return version # the version number already contains the server version, see VERSION_RE for details
|
|
return "%s.%s" % (release.major_version, version)
|
|
|
|
def _get_migration_versions(pkg, stage):
|
|
versions = sorted({
|
|
ver
|
|
for lv in self.migrations[pkg.name].values()
|
|
for ver, lf in lv.items()
|
|
if lf
|
|
}, key=lambda k: parse_version(convert_version(k)))
|
|
if "0.0.0" in versions:
|
|
# reorder versions
|
|
versions.remove("0.0.0")
|
|
if stage == "pre":
|
|
versions.insert(0, "0.0.0")
|
|
else:
|
|
versions.append("0.0.0")
|
|
return versions
|
|
|
|
def _get_migration_files(pkg, version, stage):
|
|
""" return a list of migration script files
|
|
"""
|
|
m = self.migrations[pkg.name]
|
|
|
|
return sorted(
|
|
(
|
|
f
|
|
for k in m
|
|
for f in m[k].get(version, [])
|
|
if os.path.basename(f).startswith(f"{stage}-")
|
|
),
|
|
key=os.path.basename,
|
|
)
|
|
|
|
installed_version = getattr(pkg, 'load_version', pkg.installed_version) or ''
|
|
parsed_installed_version = parse_version(installed_version)
|
|
current_version = parse_version(convert_version(pkg.data['version']))
|
|
|
|
def compare(version):
|
|
if version == "0.0.0" and parsed_installed_version < current_version:
|
|
return True
|
|
|
|
full_version = convert_version(version)
|
|
majorless_version = (version != full_version)
|
|
|
|
if majorless_version:
|
|
# We should not re-execute major-less scripts when upgrading to new Odoo version
|
|
# a module in `9.0.2.0` should not re-execute a `2.0` script when upgrading to `10.0.2.0`.
|
|
# In which case we must compare just the module version
|
|
return parsed_installed_version[2:] < parse_version(full_version)[2:] <= current_version[2:]
|
|
|
|
return parsed_installed_version < parse_version(full_version) <= current_version
|
|
|
|
versions = _get_migration_versions(pkg, stage)
|
|
for version in versions:
|
|
if compare(version):
|
|
strfmt = {'addon': pkg.name,
|
|
'stage': stage,
|
|
'version': stageformat[stage] % version,
|
|
}
|
|
|
|
for pyfile in _get_migration_files(pkg, version, stage):
|
|
name, ext = os.path.splitext(os.path.basename(pyfile))
|
|
if ext.lower() != '.py':
|
|
continue
|
|
mod = None
|
|
try:
|
|
mod = load_script(pyfile, name)
|
|
_logger.info('module %(addon)s: Running migration %(version)s %(name)s' % dict(strfmt, name=mod.__name__))
|
|
migrate = mod.migrate
|
|
except ImportError:
|
|
_logger.exception('module %(addon)s: Unable to load %(stage)s-migration file %(file)s' % dict(strfmt, file=pyfile))
|
|
raise
|
|
except AttributeError:
|
|
_logger.error('module %(addon)s: Each %(stage)s-migration file must have a "migrate(cr, installed_version)" function' % strfmt)
|
|
else:
|
|
migrate(self.cr, installed_version)
|
|
finally:
|
|
if mod:
|
|
del mod
|